From 347d32ecfa54f4b5b17a1c441483b1e7ce120b62 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:42:39 -0700 Subject: [PATCH 01/16] add consent-dependent exposure handler --- packages/experiment-tag/src/experiment.ts | 10 + .../util/consent-aware-exposure-handler.ts | 77 ++++++ .../consent-aware-exposure-handler.test.ts | 245 ++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 packages/experiment-tag/src/util/consent-aware-exposure-handler.ts create mode 100644 packages/experiment-tag/test/consent-aware-exposure-handler.test.ts diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 681789e4..b3bdef74 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -40,6 +40,7 @@ import { getInjectUtils } from './util/inject-utils'; import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger'; import { patchRemoveChild } from './util/patch'; import { ConsentAwareStorage } from './util/storage'; +import { ConsentAwareExposureHandler } from './util/consent-aware-exposure-handler'; import { getUrlParams, removeQueryParams, @@ -105,6 +106,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { status: ConsentStatus.GRANTED, }; private storage: ConsentAwareStorage; + private consentAwareExposureHandler: ConsentAwareExposureHandler; constructor( apiKey: string, @@ -136,6 +138,12 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // Initialize consent-aware storage this.storage = new ConsentAwareStorage(this.consentOptions.status); + // Initialize consent-aware exposure handler + this.consentAwareExposureHandler = new ConsentAwareExposureHandler( + this.consentOptions.status, + this.config.exposureTrackingProvider, + ); + this.initialFlags.forEach((flag: EvaluationFlag) => { const { key, variants, metadata = {} } = flag; @@ -164,6 +172,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { fetchOnStart: false, automaticExposureTracking: false, ...this.config, + exposureTrackingProvider: this.consentAwareExposureHandler, }); // Get all the locally available flag keys from the SDK. const variants = this.experimentClient.all(); @@ -538,6 +547,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.consentOptions.status = consentStatus; // Update storage consent status to handle persistence behavior this.storage.setConsentStatus(consentStatus); + this.consentAwareExposureHandler.setConsentStatus(consentStatus); } private async fetchRemoteFlags() { diff --git a/packages/experiment-tag/src/util/consent-aware-exposure-handler.ts b/packages/experiment-tag/src/util/consent-aware-exposure-handler.ts new file mode 100644 index 00000000..b1d487c6 --- /dev/null +++ b/packages/experiment-tag/src/util/consent-aware-exposure-handler.ts @@ -0,0 +1,77 @@ +import { + Exposure, + ExposureTrackingProvider, +} from '@amplitude/experiment-js-client'; + +import { ConsentStatus } from '../types'; + +/** + * Consent-aware exposure handler that manages exposure tracking based on consent status + */ +export class ConsentAwareExposureHandler implements ExposureTrackingProvider { + private pendingExposures: Exposure[] = []; + private consentStatus: ConsentStatus = ConsentStatus.PENDING; + private exposureTrackingProvider?: ExposureTrackingProvider; + + constructor( + initialConsentStatus: ConsentStatus, + exposureTrackingProvider?: ExposureTrackingProvider, + ) { + this.consentStatus = initialConsentStatus; + this.exposureTrackingProvider = exposureTrackingProvider; + } + + /** + * Set the consent status and handle exposure tracking accordingly + */ + public setConsentStatus(consentStatus: ConsentStatus): void { + const previousStatus = this.consentStatus; + this.consentStatus = consentStatus; + + if (previousStatus === ConsentStatus.PENDING) { + if (consentStatus === ConsentStatus.GRANTED) { + // Fire all pending exposures when consent is granted + for (const exposure of this.pendingExposures) { + this.trackExposureDirectly(exposure); + } + this.pendingExposures = []; + } else if (consentStatus === ConsentStatus.REJECTED) { + // Delete all pending exposures when consent is rejected + this.pendingExposures = []; + } + } + } + + /** + * Set the exposure tracking provider + */ + public setExposureTrackingProvider( + exposureTrackingProvider: ExposureTrackingProvider, + ): void { + this.exposureTrackingProvider = exposureTrackingProvider; + } + + /** + * Track an exposure with consent awareness + */ + public track(exposure: Exposure): void { + if (this.consentStatus === ConsentStatus.PENDING) { + this.pendingExposures.push(exposure); + } else if (this.consentStatus === ConsentStatus.GRANTED) { + this.trackExposureDirectly(exposure); + } + } + + /** + * Track exposure directly using the underlying provider + */ + private trackExposureDirectly(exposure: Exposure): void { + if (this.exposureTrackingProvider) { + try { + this.exposureTrackingProvider.track(exposure); + } catch (error) { + console.warn('Failed to track exposure:', error); + } + } + } +} diff --git a/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts new file mode 100644 index 00000000..47ff3e02 --- /dev/null +++ b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts @@ -0,0 +1,245 @@ +import { + Exposure, + ExposureTrackingProvider, +} from '@amplitude/experiment-js-client'; +import { ConsentStatus } from 'src/types'; +import { ConsentAwareExposureHandler } from 'src/util/consent-aware-exposure-handler'; + +class TestExposureTrackingProvider implements ExposureTrackingProvider { + public trackedExposures: Exposure[] = []; + public trackCount = 0; + + track(exposure: Exposure): void { + this.trackCount += 1; + this.trackedExposures.push(exposure); + } + + reset(): void { + this.trackedExposures = []; + this.trackCount = 0; + } +} + +describe('ConsentAwareExposureHandler', () => { + let provider: TestExposureTrackingProvider; + let handler: ConsentAwareExposureHandler; + + beforeEach(() => { + provider = new TestExposureTrackingProvider(); + }); + + describe('when consent is granted', () => { + beforeEach(() => { + handler = new ConsentAwareExposureHandler( + ConsentStatus.GRANTED, + provider, + ); + }); + + test('should track exposures immediately', () => { + const exposure: Exposure = { + flag_key: 'test-flag', + variant: 'test-variant', + }; + + handler.track(exposure); + + expect(provider.trackCount).toBe(1); + expect(provider.trackedExposures).toEqual([exposure]); + }); + + test('should track multiple exposures immediately', () => { + const exposure1: Exposure = { flag_key: 'flag1', variant: 'variant1' }; + const exposure2: Exposure = { flag_key: 'flag2', variant: 'variant2' }; + + handler.track(exposure1); + handler.track(exposure2); + + expect(provider.trackCount).toBe(2); + expect(provider.trackedExposures).toEqual([exposure1, exposure2]); + }); + }); + + describe('when consent is pending', () => { + beforeEach(() => { + handler = new ConsentAwareExposureHandler( + ConsentStatus.PENDING, + provider, + ); + }); + + test('should not track exposures immediately', () => { + const exposure: Exposure = { + flag_key: 'test-flag', + variant: 'test-variant', + }; + + handler.track(exposure); + + expect(provider.trackCount).toBe(0); + expect(provider.trackedExposures).toEqual([]); + }); + + test('should store multiple exposures in memory', () => { + const exposure1: Exposure = { flag_key: 'flag1', variant: 'variant1' }; + const exposure2: Exposure = { flag_key: 'flag2', variant: 'variant2' }; + + handler.track(exposure1); + handler.track(exposure2); + + expect(provider.trackCount).toBe(0); + expect(provider.trackedExposures).toEqual([]); + }); + + test('should fire all pending exposures when consent becomes granted', () => { + const exposure1: Exposure = { flag_key: 'flag1', variant: 'variant1' }; + const exposure2: Exposure = { flag_key: 'flag2', variant: 'variant2' }; + + handler.track(exposure1); + handler.track(exposure2); + + // Change consent to granted + handler.setConsentStatus(ConsentStatus.GRANTED); + + expect(provider.trackCount).toBe(2); + expect(provider.trackedExposures).toEqual([exposure1, exposure2]); + }); + + test('should track new exposures immediately after consent becomes granted', () => { + const exposure1: Exposure = { flag_key: 'flag1', variant: 'variant1' }; + const exposure2: Exposure = { flag_key: 'flag2', variant: 'variant2' }; + + handler.track(exposure1); + handler.setConsentStatus(ConsentStatus.GRANTED); + handler.track(exposure2); + + expect(provider.trackCount).toBe(2); + expect(provider.trackedExposures).toEqual([exposure1, exposure2]); + }); + + test('should delete all pending exposures when consent becomes rejected', () => { + const exposure1: Exposure = { flag_key: 'flag1', variant: 'variant1' }; + const exposure2: Exposure = { flag_key: 'flag2', variant: 'variant2' }; + + handler.track(exposure1); + handler.track(exposure2); + + // Change consent to rejected + handler.setConsentStatus(ConsentStatus.REJECTED); + + expect(provider.trackCount).toBe(0); + expect(provider.trackedExposures).toEqual([]); + + // Even if consent becomes granted later, the previously pending exposures should not be fired + handler.setConsentStatus(ConsentStatus.GRANTED); + expect(provider.trackCount).toBe(0); + expect(provider.trackedExposures).toEqual([]); + }); + }); + + describe('when consent is rejected', () => { + beforeEach(() => { + handler = new ConsentAwareExposureHandler( + ConsentStatus.REJECTED, + provider, + ); + }); + + test('should not track exposures', () => { + const exposure: Exposure = { + flag_key: 'test-flag', + variant: 'test-variant', + }; + + handler.track(exposure); + + expect(provider.trackCount).toBe(0); + expect(provider.trackedExposures).toEqual([]); + }); + + test('should track exposures when consent becomes granted', () => { + const exposure: Exposure = { + flag_key: 'test-flag', + variant: 'test-variant', + }; + + handler.track(exposure); + expect(provider.trackCount).toBe(0); + + handler.setConsentStatus(ConsentStatus.GRANTED); + handler.track(exposure); + + expect(provider.trackCount).toBe(1); + expect(provider.trackedExposures).toEqual([exposure]); + }); + }); + + describe('without exposure tracking provider', () => { + beforeEach(() => { + handler = new ConsentAwareExposureHandler(ConsentStatus.GRANTED); + }); + + test('should not throw error when tracking exposures', () => { + const exposure: Exposure = { + flag_key: 'test-flag', + variant: 'test-variant', + }; + + expect(() => handler.track(exposure)).not.toThrow(); + }); + }); + + describe('setExposureTrackingProvider', () => { + beforeEach(() => { + handler = new ConsentAwareExposureHandler(ConsentStatus.GRANTED); + }); + + test('should set the exposure tracking provider', () => { + const exposure: Exposure = { + flag_key: 'test-flag', + variant: 'test-variant', + }; + + handler.track(exposure); + expect(provider.trackCount).toBe(0); + + handler.setExposureTrackingProvider(provider); + handler.track(exposure); + + expect(provider.trackCount).toBe(1); + expect(provider.trackedExposures).toEqual([exposure]); + }); + }); + + describe('error handling', () => { + let errorProvider: ExposureTrackingProvider; + + beforeEach(() => { + errorProvider = { + track: () => { + throw new Error('Tracking failed'); + }, + }; + handler = new ConsentAwareExposureHandler( + ConsentStatus.GRANTED, + errorProvider, + ); + }); + + test('should handle errors gracefully and log warning', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const exposure: Exposure = { + flag_key: 'test-flag', + variant: 'test-variant', + }; + + expect(() => handler.track(exposure)).not.toThrow(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to track exposure:', + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + }); +}); From ae74942772f37a3b4c5483317c3bd07456d74d53 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 2 Oct 2025 13:16:29 -0700 Subject: [PATCH 02/16] fix import --- packages/experiment-tag/src/experiment.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 92e1707f..87449b6e 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -36,11 +36,10 @@ import { RevertVariantsOptions, } from './types'; import { applyAntiFlickerCss } from './util/anti-flicker'; +import { ConsentAwareExposureHandler } from './util/consent-aware-exposure-handler'; import { getInjectUtils } from './util/inject-utils'; import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger'; import { patchRemoveChild } from './util/patch'; -import { ConsentAwareStorage } from './util/storage'; -import { ConsentAwareExposureHandler } from './util/consent-aware-exposure-handler'; import { getUrlParams, removeQueryParams, From 159ff2090d3199405d85c89f84c02454f4986fca Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 2 Oct 2025 15:26:25 -0700 Subject: [PATCH 03/16] move exposure handler --- packages/experiment-tag/src/experiment.ts | 2 +- .../src/{util => exposure}/consent-aware-exposure-handler.ts | 0 .../experiment-tag/test/consent-aware-exposure-handler.test.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/experiment-tag/src/{util => exposure}/consent-aware-exposure-handler.ts (100%) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 87449b6e..927f6c34 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -36,7 +36,7 @@ import { RevertVariantsOptions, } from './types'; import { applyAntiFlickerCss } from './util/anti-flicker'; -import { ConsentAwareExposureHandler } from './util/consent-aware-exposure-handler'; +import { ConsentAwareExposureHandler } from './exposure/consent-aware-exposure-handler'; import { getInjectUtils } from './util/inject-utils'; import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger'; import { patchRemoveChild } from './util/patch'; diff --git a/packages/experiment-tag/src/util/consent-aware-exposure-handler.ts b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts similarity index 100% rename from packages/experiment-tag/src/util/consent-aware-exposure-handler.ts rename to packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts diff --git a/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts index 47ff3e02..dc020f09 100644 --- a/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts +++ b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts @@ -3,7 +3,7 @@ import { ExposureTrackingProvider, } from '@amplitude/experiment-js-client'; import { ConsentStatus } from 'src/types'; -import { ConsentAwareExposureHandler } from 'src/util/consent-aware-exposure-handler'; +import { ConsentAwareExposureHandler } from 'src/exposure/consent-aware-exposure-handler'; class TestExposureTrackingProvider implements ExposureTrackingProvider { public trackedExposures: Exposure[] = []; From 394f169edb88cab77b5b90d2151710a506b6e1ba Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:05:37 -0700 Subject: [PATCH 04/16] fix lint --- packages/experiment-tag/src/experiment.ts | 2 +- .../experiment-tag/test/consent-aware-exposure-handler.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 927f6c34..5f6c3309 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -15,6 +15,7 @@ import { import * as FeatureExperiment from '@amplitude/experiment-js-client'; import mutate, { MutationController } from 'dom-mutator'; +import { ConsentAwareExposureHandler } from './exposure/consent-aware-exposure-handler'; import { MessageBus } from './message-bus'; import { showPreviewModeModal } from './preview/preview'; import { ConsentAwareStorage } from './storage/consent-aware-storage'; @@ -36,7 +37,6 @@ import { RevertVariantsOptions, } from './types'; import { applyAntiFlickerCss } from './util/anti-flicker'; -import { ConsentAwareExposureHandler } from './exposure/consent-aware-exposure-handler'; import { getInjectUtils } from './util/inject-utils'; import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger'; import { patchRemoveChild } from './util/patch'; diff --git a/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts index dc020f09..8ea25f05 100644 --- a/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts +++ b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts @@ -2,8 +2,8 @@ import { Exposure, ExposureTrackingProvider, } from '@amplitude/experiment-js-client'; -import { ConsentStatus } from 'src/types'; import { ConsentAwareExposureHandler } from 'src/exposure/consent-aware-exposure-handler'; +import { ConsentStatus } from 'src/types'; class TestExposureTrackingProvider implements ExposureTrackingProvider { public trackedExposures: Exposure[] = []; From 9034cde6e0bbfcfb53887cc138715157e9b23b9a Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:50:58 -0700 Subject: [PATCH 05/16] add test cases --- .../consent-aware-exposure-handler.ts | 1 - .../experiment-tag/test/experiment.test.ts | 213 ++++++++++++++++++ 2 files changed, 213 insertions(+), 1 deletion(-) diff --git a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts index b1d487c6..d09b44bf 100644 --- a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts +++ b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts @@ -36,7 +36,6 @@ export class ConsentAwareExposureHandler implements ExposureTrackingProvider { } this.pendingExposures = []; } else if (consentStatus === ConsentStatus.REJECTED) { - // Delete all pending exposures when consent is rejected this.pendingExposures = []; } } diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 1cdc7263..1a886374 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1377,6 +1377,219 @@ describe('initializeExperiment', () => { }); }); + describe('consent-aware exposure handling', () => { + let mockExposureTrackingProvider: any; + + beforeEach(() => { + mockExposureTrackingProvider = { + track: jest.fn(), + }; + }); + + it('should store exposures in memory when consent is PENDING', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + { + exposureTrackingProvider: mockExposureTrackingProvider, + }, + ); + + client.start(); + + // Exposure should be called but not tracked to the provider yet + expect(mockExposure).toHaveBeenCalledWith('test'); + expect(mockExposureTrackingProvider.track).not.toHaveBeenCalled(); + }); + + it('should track exposures immediately when consent is GRANTED', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.GRANTED, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + { + exposureTrackingProvider: mockExposureTrackingProvider, + }, + ); + + client.start(); + + // Exposure should be called and tracked to the provider immediately + expect(mockExposure).toHaveBeenCalledWith('test'); + expect(mockExposureTrackingProvider.track).toHaveBeenCalledWith( + expect.objectContaining({ + flag_key: 'test', + }), + ); + }); + + it('should not track exposures when consent is REJECTED', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.REJECTED, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + { + exposureTrackingProvider: mockExposureTrackingProvider, + }, + ); + + client.start(); + + // Exposure should be called but not tracked to the provider + expect(mockExposure).toHaveBeenCalledWith('test'); + expect(mockExposureTrackingProvider.track).not.toHaveBeenCalled(); + }); + + it('should fire all pending exposures when consent changes from PENDING to GRANTED', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test-flag-1', 'treatment', [DEFAULT_MUTATE_SCOPE]), + createMutateFlag('test-flag-2', 'control', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify({ + 'test-flag-1': createPageObject( + 'A', + 'url_change', + undefined, + 'http://test.com', + ), + 'test-flag-2': createPageObject( + 'A', + 'url_change', + undefined, + 'http://test.com', + ), + }), + { + exposureTrackingProvider: mockExposureTrackingProvider, + }, + ); + + client.start(); + + // Both exposures should be called but not tracked yet + expect(mockExposure).toHaveBeenCalledWith('test-flag-1'); + expect(mockExposure).toHaveBeenCalledWith('test-flag-2'); + expect(mockExposureTrackingProvider.track).not.toHaveBeenCalled(); + + // Clear mock calls to focus on consent change behavior + mockExposureTrackingProvider.track.mockClear(); + + // Change consent to granted + client.setConsentStatus(ConsentStatus.GRANTED); + + // All pending exposures should now be tracked + expect(mockExposureTrackingProvider.track).toHaveBeenCalledTimes(2); + expect(mockExposureTrackingProvider.track).toHaveBeenCalledWith( + expect.objectContaining({ + flag_key: 'test-flag-1', + }), + ); + expect(mockExposureTrackingProvider.track).toHaveBeenCalledWith( + expect.objectContaining({ + flag_key: 'test-flag-2', + }), + ); + }); + + it('should delete all pending exposures when consent changes from PENDING to REJECTED', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test-flag-1', 'treatment', [DEFAULT_MUTATE_SCOPE]), + createMutateFlag('test-flag-2', 'control', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify({ + 'test-flag-1': createPageObject( + 'A', + 'url_change', + undefined, + 'http://test.com', + ), + 'test-flag-2': createPageObject( + 'A', + 'url_change', + undefined, + 'http://test.com', + ), + }), + { + exposureTrackingProvider: mockExposureTrackingProvider, + }, + ); + + client.start(); + + // Both exposures should be called but not tracked yet + expect(mockExposure).toHaveBeenCalledWith('test-flag-1'); + expect(mockExposure).toHaveBeenCalledWith('test-flag-2'); + expect(mockExposureTrackingProvider.track).not.toHaveBeenCalled(); + + // Change consent to rejected + client.setConsentStatus(ConsentStatus.REJECTED); + + // No exposures should be tracked + expect(mockExposureTrackingProvider.track).not.toHaveBeenCalled(); + + // Even if consent becomes granted later, the previously pending exposures should not be fired + client.setConsentStatus(ConsentStatus.GRANTED); + expect(mockExposureTrackingProvider.track).not.toHaveBeenCalled(); + }); + }); + describe('marketing cookie with different consent status', () => { let mockCampaignParser: any; let mockCookieStorage: any; From c55b91b7525ea569f367a78c2ac23a3fbf26687c Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Mon, 6 Oct 2025 11:58:14 -0700 Subject: [PATCH 06/16] fix: attach timestamp to impression events (#223) --- packages/experiment-browser/src/types/exposure.ts | 4 ++++ .../src/exposure/consent-aware-exposure-handler.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/experiment-browser/src/types/exposure.ts b/packages/experiment-browser/src/types/exposure.ts index 237f8ef6..53c78581 100644 --- a/packages/experiment-browser/src/types/exposure.ts +++ b/packages/experiment-browser/src/types/exposure.ts @@ -45,6 +45,10 @@ export type Exposure = { * evaluation for the user. Used for system purposes. */ metadata?: Record; + /** + * (Optional) The time the exposure occurred. + */ + time?: number; }; /** diff --git a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts index d09b44bf..f3363614 100644 --- a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts +++ b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts @@ -54,6 +54,7 @@ export class ConsentAwareExposureHandler implements ExposureTrackingProvider { * Track an exposure with consent awareness */ public track(exposure: Exposure): void { + exposure.time = new Date().getTime(); if (this.consentStatus === ConsentStatus.PENDING) { this.pendingExposures.push(exposure); } else if (this.consentStatus === ConsentStatus.GRANTED) { From f8b2a89e6c1a9fe089eda651280f2a977dc2cca3 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Mon, 6 Oct 2025 14:24:35 -0700 Subject: [PATCH 07/16] add timestamp tests --- .../consent-aware-exposure-handler.test.ts | 116 +++++++++++++++++- .../experiment-tag/test/experiment.test.ts | 6 - 2 files changed, 113 insertions(+), 9 deletions(-) diff --git a/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts index 8ea25f05..730b09dc 100644 --- a/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts +++ b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts @@ -23,9 +23,19 @@ class TestExposureTrackingProvider implements ExposureTrackingProvider { describe('ConsentAwareExposureHandler', () => { let provider: TestExposureTrackingProvider; let handler: ConsentAwareExposureHandler; + let mockDate: jest.SpyInstance; beforeEach(() => { provider = new TestExposureTrackingProvider(); + if (mockDate) { + mockDate.mockRestore(); + } + }); + + afterEach(() => { + if (mockDate) { + mockDate.mockRestore(); + } }); describe('when consent is granted', () => { @@ -98,7 +108,6 @@ describe('ConsentAwareExposureHandler', () => { handler.track(exposure1); handler.track(exposure2); - // Change consent to granted handler.setConsentStatus(ConsentStatus.GRANTED); expect(provider.trackCount).toBe(2); @@ -124,13 +133,11 @@ describe('ConsentAwareExposureHandler', () => { handler.track(exposure1); handler.track(exposure2); - // Change consent to rejected handler.setConsentStatus(ConsentStatus.REJECTED); expect(provider.trackCount).toBe(0); expect(provider.trackedExposures).toEqual([]); - // Even if consent becomes granted later, the previously pending exposures should not be fired handler.setConsentStatus(ConsentStatus.GRANTED); expect(provider.trackCount).toBe(0); expect(provider.trackedExposures).toEqual([]); @@ -211,6 +218,89 @@ describe('ConsentAwareExposureHandler', () => { }); }); + describe('timestamp handling', () => { + beforeEach(() => { + handler = new ConsentAwareExposureHandler( + ConsentStatus.GRANTED, + provider, + ); + }); + + test('should add timestamp to exposures when tracking immediately', () => { + const mockTimestamp = 1234567890000; + mockDate = jest + .spyOn(Date.prototype, 'getTime') + .mockReturnValue(mockTimestamp); + + const exposure: Exposure = { + flag_key: 'test-flag', + variant: 'test-variant', + }; + + handler.track(exposure); + + expect(provider.trackCount).toBe(1); + const trackedExposure = provider.trackedExposures[0]; + expect(trackedExposure.time).toBe(mockTimestamp); + expect(mockDate).toHaveBeenCalled(); + }); + + test('should add timestamp to pending exposures when stored', () => { + handler = new ConsentAwareExposureHandler( + ConsentStatus.PENDING, + provider, + ); + + const mockTimestamp = 1234567890000; + mockDate = jest + .spyOn(Date.prototype, 'getTime') + .mockReturnValue(mockTimestamp); + + const exposure: Exposure = { + flag_key: 'test-flag', + variant: 'test-variant', + }; + + handler.track(exposure); + + handler.setConsentStatus(ConsentStatus.GRANTED); + + expect(provider.trackCount).toBe(1); + const trackedExposure = provider.trackedExposures[0]; + expect(trackedExposure.time).toBe(mockTimestamp); + expect(mockDate).toHaveBeenCalled(); + }); + + test('should add different timestamps to multiple exposures', () => { + const exposure1: Exposure = { + flag_key: 'flag1', + variant: 'variant1', + }; + const exposure2: Exposure = { + flag_key: 'flag2', + variant: 'variant2', + }; + + const mockTimestamp1 = 1234567890000; + const mockTimestamp2 = 1234567891000; + mockDate = jest + .spyOn(Date.prototype, 'getTime') + .mockReturnValueOnce(mockTimestamp1) + .mockReturnValueOnce(mockTimestamp2); + + handler.track(exposure1); + handler.track(exposure2); + + expect(provider.trackCount).toBe(2); + const trackedExposure1 = provider.trackedExposures[0]; + const trackedExposure2 = provider.trackedExposures[1]; + + expect(trackedExposure1.time).toBe(mockTimestamp1); + expect(trackedExposure2.time).toBe(mockTimestamp2); + expect(mockDate).toHaveBeenCalledTimes(2); + }); + }); + describe('error handling', () => { let errorProvider: ExposureTrackingProvider; @@ -241,5 +331,25 @@ describe('ConsentAwareExposureHandler', () => { consoleSpy.mockRestore(); }); + + test('should still add timestamp even when tracking fails', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const mockTimestamp = 1234567890000; + mockDate = jest + .spyOn(Date.prototype, 'getTime') + .mockReturnValue(mockTimestamp); + + const exposure: Exposure = { + flag_key: 'test-flag', + variant: 'test-variant', + }; + + handler.track(exposure); + + expect(exposure.time).toBe(mockTimestamp); + expect(mockDate).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); }); }); diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 1a886374..4f742b59 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1516,13 +1516,10 @@ describe('initializeExperiment', () => { expect(mockExposure).toHaveBeenCalledWith('test-flag-2'); expect(mockExposureTrackingProvider.track).not.toHaveBeenCalled(); - // Clear mock calls to focus on consent change behavior mockExposureTrackingProvider.track.mockClear(); - // Change consent to granted client.setConsentStatus(ConsentStatus.GRANTED); - // All pending exposures should now be tracked expect(mockExposureTrackingProvider.track).toHaveBeenCalledTimes(2); expect(mockExposureTrackingProvider.track).toHaveBeenCalledWith( expect.objectContaining({ @@ -1573,15 +1570,12 @@ describe('initializeExperiment', () => { client.start(); - // Both exposures should be called but not tracked yet expect(mockExposure).toHaveBeenCalledWith('test-flag-1'); expect(mockExposure).toHaveBeenCalledWith('test-flag-2'); expect(mockExposureTrackingProvider.track).not.toHaveBeenCalled(); - // Change consent to rejected client.setConsentStatus(ConsentStatus.REJECTED); - // No exposures should be tracked expect(mockExposureTrackingProvider.track).not.toHaveBeenCalled(); // Even if consent becomes granted later, the previously pending exposures should not be fired From fd845445ab61d2f34b4236bd2d2be4535065cf7b Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Mon, 6 Oct 2025 14:53:58 -0700 Subject: [PATCH 08/16] simplify comments --- packages/experiment-tag/src/experiment.ts | 2 -- .../src/exposure/consent-aware-exposure-handler.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 5f6c3309..71e3e0a8 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -136,7 +136,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.storage = new ConsentAwareStorage(this.consentOptions.status); - // Initialize consent-aware exposure handler this.consentAwareExposureHandler = new ConsentAwareExposureHandler( this.consentOptions.status, this.config.exposureTrackingProvider, @@ -543,7 +542,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient { public setConsentStatus(consentStatus: ConsentStatus) { this.consentOptions.status = consentStatus; - // Update storage consent status to handle persistence behavior this.storage.setConsentStatus(consentStatus); this.consentAwareExposureHandler.setConsentStatus(consentStatus); } diff --git a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts index f3363614..5f19c146 100644 --- a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts +++ b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts @@ -30,7 +30,6 @@ export class ConsentAwareExposureHandler implements ExposureTrackingProvider { if (previousStatus === ConsentStatus.PENDING) { if (consentStatus === ConsentStatus.GRANTED) { - // Fire all pending exposures when consent is granted for (const exposure of this.pendingExposures) { this.trackExposureDirectly(exposure); } From cc414ba0f69125d2cb7d142450154ab20e90c750 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:48:51 -0700 Subject: [PATCH 09/16] wrap integrationplugin --- packages/experiment-tag/src/experiment.ts | 4 +- .../consent-aware-exposure-handler.ts | 109 +++-- .../consent-aware-exposure-handler.test.ts | 417 ++++++++---------- .../experiment-tag/test/experiment.test.ts | 206 --------- 4 files changed, 236 insertions(+), 500 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 71e3e0a8..c2597e3b 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -138,7 +138,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.consentAwareExposureHandler = new ConsentAwareExposureHandler( this.consentOptions.status, - this.config.exposureTrackingProvider, ); this.initialFlags.forEach((flag: EvaluationFlag) => { @@ -169,7 +168,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient { fetchOnStart: false, automaticExposureTracking: false, ...this.config, - exposureTrackingProvider: this.consentAwareExposureHandler, }); // Get all the locally available flag keys from the SDK. const variants = this.experimentClient.all(); @@ -273,7 +271,9 @@ export class DefaultWebExperimentClient implements WebExperimentClient { ); } this.globalScope.experimentIntegration.type = 'integration'; + this.consentAwareExposureHandler.wrapExperimentIntegrationTrack(); this.experimentClient.addPlugin(this.globalScope.experimentIntegration); + this.experimentClient.setUser(user); if (!this.isRemoteBlocking) { diff --git a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts index 5f19c146..67454c47 100644 --- a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts +++ b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts @@ -1,75 +1,88 @@ import { - Exposure, - ExposureTrackingProvider, + ExperimentEvent, + IntegrationPlugin, } from '@amplitude/experiment-js-client'; +import { getGlobalScope } from '@amplitude/experiment-core'; import { ConsentStatus } from '../types'; /** - * Consent-aware exposure handler that manages exposure tracking based on consent status + * Consent-aware exposure handler that wraps window.experimentIntegration.track */ -export class ConsentAwareExposureHandler implements ExposureTrackingProvider { - private pendingExposures: Exposure[] = []; +export class ConsentAwareExposureHandler { + private pendingEvents: ExperimentEvent[] = []; private consentStatus: ConsentStatus = ConsentStatus.PENDING; - private exposureTrackingProvider?: ExposureTrackingProvider; + private originalTrack: ((event: ExperimentEvent) => boolean) | null = null; - constructor( - initialConsentStatus: ConsentStatus, - exposureTrackingProvider?: ExposureTrackingProvider, - ) { + constructor(initialConsentStatus: ConsentStatus) { this.consentStatus = initialConsentStatus; - this.exposureTrackingProvider = exposureTrackingProvider; } /** - * Set the consent status and handle exposure tracking accordingly + * Wrap the experimentIntegration.track method with consent-aware logic */ - public setConsentStatus(consentStatus: ConsentStatus): void { - const previousStatus = this.consentStatus; - this.consentStatus = consentStatus; - - if (previousStatus === ConsentStatus.PENDING) { - if (consentStatus === ConsentStatus.GRANTED) { - for (const exposure of this.pendingExposures) { - this.trackExposureDirectly(exposure); - } - this.pendingExposures = []; - } else if (consentStatus === ConsentStatus.REJECTED) { - this.pendingExposures = []; - } + public wrapExperimentIntegrationTrack(): void { + const globalScope = getGlobalScope(); + const experimentIntegration = + globalScope?.experimentIntegration as IntegrationPlugin; + if (experimentIntegration?.track) { + this.originalTrack = experimentIntegration.track.bind( + experimentIntegration, + ); + experimentIntegration.track = this.createConsentAwareTrack( + this.originalTrack, + ); } } /** - * Set the exposure tracking provider + * Create a consent-aware wrapper for the track method */ - public setExposureTrackingProvider( - exposureTrackingProvider: ExposureTrackingProvider, - ): void { - this.exposureTrackingProvider = exposureTrackingProvider; + private createConsentAwareTrack( + originalTrack: (event: ExperimentEvent) => boolean, + ) { + return (event: ExperimentEvent): boolean => { + if (event?.eventProperties) { + event.eventProperties.time = new Date().getTime(); + } + try { + if (this.consentStatus === ConsentStatus.PENDING) { + this.pendingEvents.push(event); + return true; + } else if (this.consentStatus === ConsentStatus.GRANTED) { + return originalTrack(event); + } + return false; + } catch (error) { + console.warn('Failed to track event:', error); + return false; + } + }; } /** - * Track an exposure with consent awareness + * Set the consent status and handle pending events accordingly */ - public track(exposure: Exposure): void { - exposure.time = new Date().getTime(); - if (this.consentStatus === ConsentStatus.PENDING) { - this.pendingExposures.push(exposure); - } else if (this.consentStatus === ConsentStatus.GRANTED) { - this.trackExposureDirectly(exposure); - } - } + public setConsentStatus(consentStatus: ConsentStatus): void { + const previousStatus = this.consentStatus; + this.consentStatus = consentStatus; - /** - * Track exposure directly using the underlying provider - */ - private trackExposureDirectly(exposure: Exposure): void { - if (this.exposureTrackingProvider) { - try { - this.exposureTrackingProvider.track(exposure); - } catch (error) { - console.warn('Failed to track exposure:', error); + if (previousStatus === ConsentStatus.PENDING) { + if (consentStatus === ConsentStatus.GRANTED) { + // Fire all pending events + for (const event of this.pendingEvents) { + if (this.originalTrack) { + try { + this.originalTrack(event); + } catch (error) { + console.warn('Failed to track pending event:', error); + } + } + } + this.pendingEvents = []; + } else if (consentStatus === ConsentStatus.REJECTED) { + // Delete all pending events + this.pendingEvents = []; } } } diff --git a/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts index 730b09dc..a21de5a6 100644 --- a/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts +++ b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts @@ -1,355 +1,284 @@ import { - Exposure, - ExposureTrackingProvider, + ExperimentEvent, + IntegrationPlugin, } from '@amplitude/experiment-js-client'; import { ConsentAwareExposureHandler } from 'src/exposure/consent-aware-exposure-handler'; import { ConsentStatus } from 'src/types'; -class TestExposureTrackingProvider implements ExposureTrackingProvider { - public trackedExposures: Exposure[] = []; +class TestIntegrationPlugin implements IntegrationPlugin { + public trackedEvents: ExperimentEvent[] = []; public trackCount = 0; + public type = 'integration' as const; + public originalTrack: (event: ExperimentEvent) => boolean; - track(exposure: Exposure): void { + constructor() { + this.originalTrack = this.track.bind(this); + } + + track(event: ExperimentEvent): boolean { this.trackCount += 1; - this.trackedExposures.push(exposure); + this.trackedEvents.push(event); + return true; + } + + getUser() { + return { + user_id: 'test-user', + device_id: 'test-device', + }; } reset(): void { - this.trackedExposures = []; + this.trackedEvents = []; this.trackCount = 0; + // Restore original track method + this.track = this.originalTrack; } } describe('ConsentAwareExposureHandler', () => { - let provider: TestExposureTrackingProvider; + let integrationPlugin: TestIntegrationPlugin; let handler: ConsentAwareExposureHandler; - let mockDate: jest.SpyInstance; + let mockGlobalScope: any; + let mockGetGlobalScope: jest.SpyInstance; beforeEach(() => { - provider = new TestExposureTrackingProvider(); - if (mockDate) { - mockDate.mockRestore(); - } + integrationPlugin = new TestIntegrationPlugin(); + mockGlobalScope = { + experimentIntegration: integrationPlugin, + }; + mockGetGlobalScope = jest.spyOn(require('@amplitude/experiment-core'), 'getGlobalScope'); + mockGetGlobalScope.mockReturnValue(mockGlobalScope); }); afterEach(() => { - if (mockDate) { - mockDate.mockRestore(); + integrationPlugin.reset(); + if (mockGetGlobalScope) { + mockGetGlobalScope.mockRestore(); } }); describe('when consent is granted', () => { beforeEach(() => { - handler = new ConsentAwareExposureHandler( - ConsentStatus.GRANTED, - provider, - ); + handler = new ConsentAwareExposureHandler(ConsentStatus.GRANTED); + handler.wrapExperimentIntegrationTrack(); }); - test('should track exposures immediately', () => { - const exposure: Exposure = { - flag_key: 'test-flag', - variant: 'test-variant', + test('should track events immediately', () => { + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'test-flag', + variant: 'test-variant', + }, }; - handler.track(exposure); + // The track method should now be wrapped + mockGlobalScope.experimentIntegration.track(event); - expect(provider.trackCount).toBe(1); - expect(provider.trackedExposures).toEqual([exposure]); + expect(integrationPlugin.trackCount).toBe(1); + expect(integrationPlugin.trackedEvents).toEqual([event]); }); - test('should track multiple exposures immediately', () => { - const exposure1: Exposure = { flag_key: 'flag1', variant: 'variant1' }; - const exposure2: Exposure = { flag_key: 'flag2', variant: 'variant2' }; + test('should track multiple events immediately', () => { + const event1: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag1', variant: 'variant1' }, + }; + const event2: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag2', variant: 'variant2' }, + }; - handler.track(exposure1); - handler.track(exposure2); + mockGlobalScope.experimentIntegration.track(event1); + mockGlobalScope.experimentIntegration.track(event2); - expect(provider.trackCount).toBe(2); - expect(provider.trackedExposures).toEqual([exposure1, exposure2]); + expect(integrationPlugin.trackCount).toBe(2); + expect(integrationPlugin.trackedEvents).toEqual([event1, event2]); }); }); describe('when consent is pending', () => { beforeEach(() => { - handler = new ConsentAwareExposureHandler( - ConsentStatus.PENDING, - provider, - ); + handler = new ConsentAwareExposureHandler(ConsentStatus.PENDING); + handler.wrapExperimentIntegrationTrack(); }); - test('should not track exposures immediately', () => { - const exposure: Exposure = { - flag_key: 'test-flag', - variant: 'test-variant', + test('should not track events immediately', () => { + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'test-flag', + variant: 'test-variant', + }, }; - handler.track(exposure); + mockGlobalScope.experimentIntegration.track(event); - expect(provider.trackCount).toBe(0); - expect(provider.trackedExposures).toEqual([]); + expect(integrationPlugin.trackCount).toBe(0); + expect(integrationPlugin.trackedEvents).toEqual([]); }); - test('should store multiple exposures in memory', () => { - const exposure1: Exposure = { flag_key: 'flag1', variant: 'variant1' }; - const exposure2: Exposure = { flag_key: 'flag2', variant: 'variant2' }; + test('should store multiple events in memory', () => { + const event1: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag1', variant: 'variant1' }, + }; + const event2: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag2', variant: 'variant2' }, + }; - handler.track(exposure1); - handler.track(exposure2); + mockGlobalScope.experimentIntegration.track(event1); + mockGlobalScope.experimentIntegration.track(event2); - expect(provider.trackCount).toBe(0); - expect(provider.trackedExposures).toEqual([]); + expect(integrationPlugin.trackCount).toBe(0); + expect(integrationPlugin.trackedEvents).toEqual([]); }); - test('should fire all pending exposures when consent becomes granted', () => { - const exposure1: Exposure = { flag_key: 'flag1', variant: 'variant1' }; - const exposure2: Exposure = { flag_key: 'flag2', variant: 'variant2' }; + test('should fire all pending events when consent becomes granted', () => { + const event1: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag1', variant: 'variant1' }, + }; + const event2: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag2', variant: 'variant2' }, + }; - handler.track(exposure1); - handler.track(exposure2); + mockGlobalScope.experimentIntegration.track(event1); + mockGlobalScope.experimentIntegration.track(event2); handler.setConsentStatus(ConsentStatus.GRANTED); - expect(provider.trackCount).toBe(2); - expect(provider.trackedExposures).toEqual([exposure1, exposure2]); + expect(integrationPlugin.trackCount).toBe(2); + expect(integrationPlugin.trackedEvents).toEqual([event1, event2]); }); - test('should track new exposures immediately after consent becomes granted', () => { - const exposure1: Exposure = { flag_key: 'flag1', variant: 'variant1' }; - const exposure2: Exposure = { flag_key: 'flag2', variant: 'variant2' }; + test('should track new events immediately after consent becomes granted', () => { + const event1: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag1', variant: 'variant1' }, + }; + const event2: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag2', variant: 'variant2' }, + }; - handler.track(exposure1); + mockGlobalScope.experimentIntegration.track(event1); handler.setConsentStatus(ConsentStatus.GRANTED); - handler.track(exposure2); + mockGlobalScope.experimentIntegration.track(event2); - expect(provider.trackCount).toBe(2); - expect(provider.trackedExposures).toEqual([exposure1, exposure2]); + expect(integrationPlugin.trackCount).toBe(2); + expect(integrationPlugin.trackedEvents).toEqual([event1, event2]); }); - test('should delete all pending exposures when consent becomes rejected', () => { - const exposure1: Exposure = { flag_key: 'flag1', variant: 'variant1' }; - const exposure2: Exposure = { flag_key: 'flag2', variant: 'variant2' }; + test('should delete all pending events when consent becomes rejected', () => { + const event1: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag1', variant: 'variant1' }, + }; + const event2: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { flag_key: 'flag2', variant: 'variant2' }, + }; - handler.track(exposure1); - handler.track(exposure2); + mockGlobalScope.experimentIntegration.track(event1); + mockGlobalScope.experimentIntegration.track(event2); handler.setConsentStatus(ConsentStatus.REJECTED); - expect(provider.trackCount).toBe(0); - expect(provider.trackedExposures).toEqual([]); + expect(integrationPlugin.trackCount).toBe(0); + expect(integrationPlugin.trackedEvents).toEqual([]); handler.setConsentStatus(ConsentStatus.GRANTED); - expect(provider.trackCount).toBe(0); - expect(provider.trackedExposures).toEqual([]); + expect(integrationPlugin.trackCount).toBe(0); + expect(integrationPlugin.trackedEvents).toEqual([]); }); }); describe('when consent is rejected', () => { beforeEach(() => { - handler = new ConsentAwareExposureHandler( - ConsentStatus.REJECTED, - provider, - ); + handler = new ConsentAwareExposureHandler(ConsentStatus.REJECTED); + handler.wrapExperimentIntegrationTrack(); }); - test('should not track exposures', () => { - const exposure: Exposure = { - flag_key: 'test-flag', - variant: 'test-variant', + test('should not track events', () => { + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'test-flag', + variant: 'test-variant', + }, }; - handler.track(exposure); + mockGlobalScope.experimentIntegration.track(event); - expect(provider.trackCount).toBe(0); - expect(provider.trackedExposures).toEqual([]); + expect(integrationPlugin.trackCount).toBe(0); + expect(integrationPlugin.trackedEvents).toEqual([]); }); - test('should track exposures when consent becomes granted', () => { - const exposure: Exposure = { - flag_key: 'test-flag', - variant: 'test-variant', + test('should track events when consent becomes granted', () => { + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'test-flag', + variant: 'test-variant', + }, }; - handler.track(exposure); - expect(provider.trackCount).toBe(0); + mockGlobalScope.experimentIntegration.track(event); + expect(integrationPlugin.trackCount).toBe(0); handler.setConsentStatus(ConsentStatus.GRANTED); - handler.track(exposure); + mockGlobalScope.experimentIntegration.track(event); - expect(provider.trackCount).toBe(1); - expect(provider.trackedExposures).toEqual([exposure]); + expect(integrationPlugin.trackCount).toBe(1); + expect(integrationPlugin.trackedEvents).toEqual([event]); }); }); - describe('without exposure tracking provider', () => { + describe('without experiment integration', () => { beforeEach(() => { + mockGetGlobalScope.mockReturnValue({}); handler = new ConsentAwareExposureHandler(ConsentStatus.GRANTED); }); - test('should not throw error when tracking exposures', () => { - const exposure: Exposure = { - flag_key: 'test-flag', - variant: 'test-variant', - }; - - expect(() => handler.track(exposure)).not.toThrow(); - }); - }); - - describe('setExposureTrackingProvider', () => { - beforeEach(() => { - handler = new ConsentAwareExposureHandler(ConsentStatus.GRANTED); - }); - - test('should set the exposure tracking provider', () => { - const exposure: Exposure = { - flag_key: 'test-flag', - variant: 'test-variant', - }; - - handler.track(exposure); - expect(provider.trackCount).toBe(0); - - handler.setExposureTrackingProvider(provider); - handler.track(exposure); - - expect(provider.trackCount).toBe(1); - expect(provider.trackedExposures).toEqual([exposure]); - }); - }); - - describe('timestamp handling', () => { - beforeEach(() => { - handler = new ConsentAwareExposureHandler( - ConsentStatus.GRANTED, - provider, - ); - }); - - test('should add timestamp to exposures when tracking immediately', () => { - const mockTimestamp = 1234567890000; - mockDate = jest - .spyOn(Date.prototype, 'getTime') - .mockReturnValue(mockTimestamp); - - const exposure: Exposure = { - flag_key: 'test-flag', - variant: 'test-variant', - }; - - handler.track(exposure); - - expect(provider.trackCount).toBe(1); - const trackedExposure = provider.trackedExposures[0]; - expect(trackedExposure.time).toBe(mockTimestamp); - expect(mockDate).toHaveBeenCalled(); - }); - - test('should add timestamp to pending exposures when stored', () => { - handler = new ConsentAwareExposureHandler( - ConsentStatus.PENDING, - provider, - ); - - const mockTimestamp = 1234567890000; - mockDate = jest - .spyOn(Date.prototype, 'getTime') - .mockReturnValue(mockTimestamp); - - const exposure: Exposure = { - flag_key: 'test-flag', - variant: 'test-variant', - }; - - handler.track(exposure); - - handler.setConsentStatus(ConsentStatus.GRANTED); - - expect(provider.trackCount).toBe(1); - const trackedExposure = provider.trackedExposures[0]; - expect(trackedExposure.time).toBe(mockTimestamp); - expect(mockDate).toHaveBeenCalled(); - }); - - test('should add different timestamps to multiple exposures', () => { - const exposure1: Exposure = { - flag_key: 'flag1', - variant: 'variant1', - }; - const exposure2: Exposure = { - flag_key: 'flag2', - variant: 'variant2', - }; - - const mockTimestamp1 = 1234567890000; - const mockTimestamp2 = 1234567891000; - mockDate = jest - .spyOn(Date.prototype, 'getTime') - .mockReturnValueOnce(mockTimestamp1) - .mockReturnValueOnce(mockTimestamp2); - - handler.track(exposure1); - handler.track(exposure2); - - expect(provider.trackCount).toBe(2); - const trackedExposure1 = provider.trackedExposures[0]; - const trackedExposure2 = provider.trackedExposures[1]; - - expect(trackedExposure1.time).toBe(mockTimestamp1); - expect(trackedExposure2.time).toBe(mockTimestamp2); - expect(mockDate).toHaveBeenCalledTimes(2); + test('should not throw error when no integration exists', () => { + expect(() => handler.setConsentStatus(ConsentStatus.PENDING)).not.toThrow(); }); }); describe('error handling', () => { - let errorProvider: ExposureTrackingProvider; + let errorIntegrationPlugin: IntegrationPlugin; + let errorGlobalScope: any; beforeEach(() => { - errorProvider = { + errorIntegrationPlugin = { + type: 'integration' as const, track: () => { throw new Error('Tracking failed'); }, + getUser: () => ({ user_id: 'test', device_id: 'test' }), }; - handler = new ConsentAwareExposureHandler( - ConsentStatus.GRANTED, - errorProvider, - ); - }); - - test('should handle errors gracefully and log warning', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - const exposure: Exposure = { - flag_key: 'test-flag', - variant: 'test-variant', + errorGlobalScope = { + experimentIntegration: errorIntegrationPlugin, }; - - expect(() => handler.track(exposure)).not.toThrow(); - expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to track exposure:', - expect.any(Error), - ); - - consoleSpy.mockRestore(); + mockGetGlobalScope.mockReturnValue(errorGlobalScope); + handler = new ConsentAwareExposureHandler(ConsentStatus.GRANTED); + handler.wrapExperimentIntegrationTrack(); }); - test('should still add timestamp even when tracking fails', () => { - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); - const mockTimestamp = 1234567890000; - mockDate = jest - .spyOn(Date.prototype, 'getTime') - .mockReturnValue(mockTimestamp); - - const exposure: Exposure = { - flag_key: 'test-flag', - variant: 'test-variant', + test('should handle errors gracefully when original track throws', () => { + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'test-flag', + variant: 'test-variant', + }, }; - handler.track(exposure); - - expect(exposure.time).toBe(mockTimestamp); - expect(mockDate).toHaveBeenCalled(); - - consoleSpy.mockRestore(); + expect(() => errorGlobalScope.experimentIntegration.track(event)).not.toThrow(); }); }); }); diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 4f742b59..01465fb2 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1377,212 +1377,6 @@ describe('initializeExperiment', () => { }); }); - describe('consent-aware exposure handling', () => { - let mockExposureTrackingProvider: any; - - beforeEach(() => { - mockExposureTrackingProvider = { - track: jest.fn(), - }; - }); - - it('should store exposures in memory when consent is PENDING', () => { - const mockGlobal = newMockGlobal({ - experimentConfig: { - consentOptions: { - status: ConsentStatus.PENDING, - }, - }, - }); - mockGetGlobalScope.mockReturnValue(mockGlobal as any); - - const client = DefaultWebExperimentClient.getInstance( - stringify(apiKey), - JSON.stringify([ - createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), - ]), - JSON.stringify(DEFAULT_PAGE_OBJECTS), - { - exposureTrackingProvider: mockExposureTrackingProvider, - }, - ); - - client.start(); - - // Exposure should be called but not tracked to the provider yet - expect(mockExposure).toHaveBeenCalledWith('test'); - expect(mockExposureTrackingProvider.track).not.toHaveBeenCalled(); - }); - - it('should track exposures immediately when consent is GRANTED', () => { - const mockGlobal = newMockGlobal({ - experimentConfig: { - consentOptions: { - status: ConsentStatus.GRANTED, - }, - }, - }); - mockGetGlobalScope.mockReturnValue(mockGlobal as any); - - const client = DefaultWebExperimentClient.getInstance( - stringify(apiKey), - JSON.stringify([ - createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), - ]), - JSON.stringify(DEFAULT_PAGE_OBJECTS), - { - exposureTrackingProvider: mockExposureTrackingProvider, - }, - ); - - client.start(); - - // Exposure should be called and tracked to the provider immediately - expect(mockExposure).toHaveBeenCalledWith('test'); - expect(mockExposureTrackingProvider.track).toHaveBeenCalledWith( - expect.objectContaining({ - flag_key: 'test', - }), - ); - }); - - it('should not track exposures when consent is REJECTED', () => { - const mockGlobal = newMockGlobal({ - experimentConfig: { - consentOptions: { - status: ConsentStatus.REJECTED, - }, - }, - }); - mockGetGlobalScope.mockReturnValue(mockGlobal as any); - - const client = DefaultWebExperimentClient.getInstance( - stringify(apiKey), - JSON.stringify([ - createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), - ]), - JSON.stringify(DEFAULT_PAGE_OBJECTS), - { - exposureTrackingProvider: mockExposureTrackingProvider, - }, - ); - - client.start(); - - // Exposure should be called but not tracked to the provider - expect(mockExposure).toHaveBeenCalledWith('test'); - expect(mockExposureTrackingProvider.track).not.toHaveBeenCalled(); - }); - - it('should fire all pending exposures when consent changes from PENDING to GRANTED', () => { - const mockGlobal = newMockGlobal({ - experimentConfig: { - consentOptions: { - status: ConsentStatus.PENDING, - }, - }, - }); - mockGetGlobalScope.mockReturnValue(mockGlobal as any); - - const client = DefaultWebExperimentClient.getInstance( - stringify(apiKey), - JSON.stringify([ - createMutateFlag('test-flag-1', 'treatment', [DEFAULT_MUTATE_SCOPE]), - createMutateFlag('test-flag-2', 'control', [DEFAULT_MUTATE_SCOPE]), - ]), - JSON.stringify({ - 'test-flag-1': createPageObject( - 'A', - 'url_change', - undefined, - 'http://test.com', - ), - 'test-flag-2': createPageObject( - 'A', - 'url_change', - undefined, - 'http://test.com', - ), - }), - { - exposureTrackingProvider: mockExposureTrackingProvider, - }, - ); - - client.start(); - - // Both exposures should be called but not tracked yet - expect(mockExposure).toHaveBeenCalledWith('test-flag-1'); - expect(mockExposure).toHaveBeenCalledWith('test-flag-2'); - expect(mockExposureTrackingProvider.track).not.toHaveBeenCalled(); - - mockExposureTrackingProvider.track.mockClear(); - - client.setConsentStatus(ConsentStatus.GRANTED); - - expect(mockExposureTrackingProvider.track).toHaveBeenCalledTimes(2); - expect(mockExposureTrackingProvider.track).toHaveBeenCalledWith( - expect.objectContaining({ - flag_key: 'test-flag-1', - }), - ); - expect(mockExposureTrackingProvider.track).toHaveBeenCalledWith( - expect.objectContaining({ - flag_key: 'test-flag-2', - }), - ); - }); - - it('should delete all pending exposures when consent changes from PENDING to REJECTED', () => { - const mockGlobal = newMockGlobal({ - experimentConfig: { - consentOptions: { - status: ConsentStatus.PENDING, - }, - }, - }); - mockGetGlobalScope.mockReturnValue(mockGlobal as any); - - const client = DefaultWebExperimentClient.getInstance( - stringify(apiKey), - JSON.stringify([ - createMutateFlag('test-flag-1', 'treatment', [DEFAULT_MUTATE_SCOPE]), - createMutateFlag('test-flag-2', 'control', [DEFAULT_MUTATE_SCOPE]), - ]), - JSON.stringify({ - 'test-flag-1': createPageObject( - 'A', - 'url_change', - undefined, - 'http://test.com', - ), - 'test-flag-2': createPageObject( - 'A', - 'url_change', - undefined, - 'http://test.com', - ), - }), - { - exposureTrackingProvider: mockExposureTrackingProvider, - }, - ); - - client.start(); - - expect(mockExposure).toHaveBeenCalledWith('test-flag-1'); - expect(mockExposure).toHaveBeenCalledWith('test-flag-2'); - expect(mockExposureTrackingProvider.track).not.toHaveBeenCalled(); - - client.setConsentStatus(ConsentStatus.REJECTED); - - expect(mockExposureTrackingProvider.track).not.toHaveBeenCalled(); - - // Even if consent becomes granted later, the previously pending exposures should not be fired - client.setConsentStatus(ConsentStatus.GRANTED); - expect(mockExposureTrackingProvider.track).not.toHaveBeenCalled(); - }); - }); describe('marketing cookie with different consent status', () => { let mockCampaignParser: any; From 636cafbbc6020b41b8377130c9baadfa13887155 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:55:52 -0700 Subject: [PATCH 10/16] update tests --- .../experiment-tag/test/experiment.test.ts | 397 ++++++++++++++++++ 1 file changed, 397 insertions(+) diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 01465fb2..45a99219 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1507,6 +1507,403 @@ describe('initializeExperiment', () => { }); }); + describe('consent aware exposure tracking', () => { + let mockExperimentIntegration: any; + let originalTrackSpy: jest.SpyInstance; + + beforeEach(() => { + // Create fresh mock experimentIntegration for each test + originalTrackSpy = jest.fn().mockReturnValue(true); + mockExperimentIntegration = { + track: originalTrackSpy, + getUser: jest.fn().mockReturnValue({ + user_id: 'user', + device_id: 'device', + }), + type: 'integration', + }; + }); + + afterEach(() => { + // Clear experimentIntegration between tests + if (mockGlobal?.experimentIntegration) { + mockGlobal.experimentIntegration = undefined; + } + originalTrackSpy.mockClear(); + }); + + it('should track exposures immediately when consent is GRANTED', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.GRANTED, + }, + }, + experimentIntegration: mockExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + expect(originalTrackSpy).toHaveBeenCalled(); + expect(originalTrackSpy.mock.calls[0][0]).toMatchObject({ + eventType: '$impression', + eventProperties: expect.objectContaining({ + flag_key: 'test', + variant: 'treatment', + }), + }); + }); + + it('should store exposures in memory when consent is PENDING', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + experimentIntegration: mockExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + // Should not track immediately when consent is pending + expect(originalTrackSpy).not.toHaveBeenCalled(); + }); + + it('should not track exposures when consent is REJECTED', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.REJECTED, + }, + }, + experimentIntegration: mockExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + expect(originalTrackSpy).not.toHaveBeenCalled(); + }); + + it('should fire all pending exposures when consent changes from PENDING to GRANTED', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + experimentIntegration: mockExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test-1', 'treatment', [DEFAULT_MUTATE_SCOPE]), + createMutateFlag('test-2', 'control', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify({ + 'test-1': DEFAULT_PAGE_OBJECTS.test, + 'test-2': DEFAULT_PAGE_OBJECTS.test, + }), + ); + + client.start(); + + // Should not track immediately when consent is pending + expect(originalTrackSpy).not.toHaveBeenCalled(); + + // Change consent to granted + client.setConsentStatus(ConsentStatus.GRANTED); + + // Should now fire all pending exposures + expect(originalTrackSpy).toHaveBeenCalled(); + + const trackedEvents = originalTrackSpy.mock.calls.map(call => call[0]); + expect(trackedEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventType: '$impression', + eventProperties: expect.objectContaining({ + flag_key: 'test-1', + variant: 'treatment', + }), + }), + expect.objectContaining({ + eventType: '$impression', + eventProperties: expect.objectContaining({ + flag_key: 'test-2', + variant: 'control', + }), + }), + ]) + ); + }); + + it('should delete all pending exposures when consent changes from PENDING to REJECTED', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + experimentIntegration: mockExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + // Should not track immediately when consent is pending + expect(originalTrackSpy).not.toHaveBeenCalled(); + + // Change consent to rejected + client.setConsentStatus(ConsentStatus.REJECTED); + + // Should not fire any exposures + expect(originalTrackSpy).not.toHaveBeenCalled(); + + // Even if consent is later granted, should not fire the previously pending exposures + client.setConsentStatus(ConsentStatus.GRANTED); + expect(originalTrackSpy).not.toHaveBeenCalled(); + }); + + it('should track new exposures immediately after consent becomes GRANTED', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + experimentIntegration: mockExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test-1', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + // Should not track immediately when consent is pending + expect(originalTrackSpy).not.toHaveBeenCalled(); + + // Change consent to granted + client.setConsentStatus(ConsentStatus.GRANTED); + + // Should fire any pending exposures (if any were created during start) + // Clear mock calls to test new exposures + originalTrackSpy.mockClear(); + + // Trigger a new exposure by calling exposure method directly + client.getExperimentClient().exposure('test-1'); + + // Should track the new exposure immediately + expect(originalTrackSpy).toHaveBeenCalledTimes(1); + expect(originalTrackSpy.mock.calls[0][0]).toMatchObject({ + eventType: '$impression', + eventProperties: expect.objectContaining({ + flag_key: 'test-1', + variant: 'treatment', + }), + }); + }); + + it('should handle multiple consent status changes correctly', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + experimentIntegration: mockExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + // Should not track immediately when consent is pending + expect(originalTrackSpy).not.toHaveBeenCalled(); + + // Change to rejected - should delete pending exposures + client.setConsentStatus(ConsentStatus.REJECTED); + expect(originalTrackSpy).not.toHaveBeenCalled(); + + // Change to pending again + client.setConsentStatus(ConsentStatus.PENDING); + expect(originalTrackSpy).not.toHaveBeenCalled(); + + // Trigger a new exposure + client.getExperimentClient().exposure('test'); + expect(originalTrackSpy).not.toHaveBeenCalled(); + + // Change to granted - should fire the new pending exposure + client.setConsentStatus(ConsentStatus.GRANTED); + expect(originalTrackSpy).toHaveBeenCalledTimes(1); + }); + + it('should add timestamp to exposure events', () => { + const mockDate = new Date('2023-01-01T00:00:00.000Z'); + const mockGetTime = jest.spyOn(Date.prototype, 'getTime').mockReturnValue(mockDate.getTime()); + + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.GRANTED, + }, + }, + experimentIntegration: mockExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + expect(originalTrackSpy).toHaveBeenCalled(); + expect(originalTrackSpy.mock.calls[0][0]).toMatchObject({ + eventType: '$impression', + eventProperties: expect.objectContaining({ + flag_key: 'test', + variant: 'treatment', + time: mockDate.getTime(), + }), + }); + + mockGetTime.mockRestore(); + }); + + it('should handle exposure tracking errors gracefully', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const errorExperimentIntegration = { + track: jest.fn().mockImplementation(() => { + throw new Error('Tracking failed'); + }), + getUser: jest.fn().mockReturnValue({ + user_id: 'user', + device_id: 'device', + }), + type: 'integration', + }; + + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.GRANTED, + }, + }, + experimentIntegration: errorExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + // Should not throw an error + expect(() => client.start()).not.toThrow(); + + expect(consoleWarnSpy).toHaveBeenCalledWith('Failed to track event:', expect.any(Error)); + consoleWarnSpy.mockRestore(); + }); + + it('should handle pending exposure tracking errors gracefully when consent becomes granted', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const errorExperimentIntegration = { + track: jest.fn().mockImplementation(() => { + throw new Error('Tracking failed'); + }), + getUser: jest.fn().mockReturnValue({ + user_id: 'user', + device_id: 'device', + }), + type: 'integration', + }; + + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + experimentIntegration: errorExperimentIntegration, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + // Should not throw an error when changing consent status + expect(() => client.setConsentStatus(ConsentStatus.GRANTED)).not.toThrow(); + + expect(consoleWarnSpy).toHaveBeenCalledWith('Failed to track pending event:', expect.any(Error)); + consoleWarnSpy.mockRestore(); + }); + }); + describe('remote evaluation - flag already stored in session storage', () => { const sessionStorageMock = () => { let store = {}; From fb46694f93e92c46ad070fbafec98404bb1a2356 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Tue, 7 Oct 2025 09:49:08 -0700 Subject: [PATCH 11/16] check track wrapping, finx lint --- .../consent-aware-exposure-handler.ts | 29 ++++++++++--- .../consent-aware-exposure-handler.test.ts | 16 ++++--- .../experiment-tag/test/experiment.test.ts | 43 +++++++------------ 3 files changed, 49 insertions(+), 39 deletions(-) diff --git a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts index 67454c47..b5216414 100644 --- a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts +++ b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts @@ -1,8 +1,8 @@ +import { getGlobalScope } from '@amplitude/experiment-core'; import { ExperimentEvent, IntegrationPlugin, } from '@amplitude/experiment-js-client'; -import { getGlobalScope } from '@amplitude/experiment-core'; import { ConsentStatus } from '../types'; @@ -13,6 +13,7 @@ export class ConsentAwareExposureHandler { private pendingEvents: ExperimentEvent[] = []; private consentStatus: ConsentStatus = ConsentStatus.PENDING; private originalTrack: ((event: ExperimentEvent) => boolean) | null = null; + private isWrapped = false; constructor(initialConsentStatus: ConsentStatus) { this.consentStatus = initialConsentStatus; @@ -20,21 +21,40 @@ export class ConsentAwareExposureHandler { /** * Wrap the experimentIntegration.track method with consent-aware logic + * Prevents nested wrapping by checking if already wrapped */ public wrapExperimentIntegrationTrack(): void { + if (this.isWrapped) { + return; + } + const globalScope = getGlobalScope(); const experimentIntegration = globalScope?.experimentIntegration as IntegrationPlugin; if (experimentIntegration?.track) { + if (this.isTrackMethodWrapped(experimentIntegration.track)) { + return; + } + this.originalTrack = experimentIntegration.track.bind( experimentIntegration, ); - experimentIntegration.track = this.createConsentAwareTrack( - this.originalTrack, - ); + const wrappedTrack = this.createConsentAwareTrack(this.originalTrack); + (wrappedTrack as any).__isConsentAwareWrapped = true; + experimentIntegration.track = wrappedTrack; + this.isWrapped = true; } } + /** + * Check if a track method is already wrapped + */ + private isTrackMethodWrapped( + trackMethod: (event: ExperimentEvent) => boolean, + ): boolean { + return (trackMethod as any).__isConsentAwareWrapped === true; + } + /** * Create a consent-aware wrapper for the track method */ @@ -69,7 +89,6 @@ export class ConsentAwareExposureHandler { if (previousStatus === ConsentStatus.PENDING) { if (consentStatus === ConsentStatus.GRANTED) { - // Fire all pending events for (const event of this.pendingEvents) { if (this.originalTrack) { try { diff --git a/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts index a21de5a6..da4d5a59 100644 --- a/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts +++ b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts @@ -1,3 +1,4 @@ +import * as experimentCore from '@amplitude/experiment-core'; import { ExperimentEvent, IntegrationPlugin, @@ -37,25 +38,22 @@ class TestIntegrationPlugin implements IntegrationPlugin { } describe('ConsentAwareExposureHandler', () => { + const mockGetGlobalScope = jest.spyOn(experimentCore, 'getGlobalScope'); let integrationPlugin: TestIntegrationPlugin; let handler: ConsentAwareExposureHandler; let mockGlobalScope: any; - let mockGetGlobalScope: jest.SpyInstance; beforeEach(() => { integrationPlugin = new TestIntegrationPlugin(); mockGlobalScope = { experimentIntegration: integrationPlugin, }; - mockGetGlobalScope = jest.spyOn(require('@amplitude/experiment-core'), 'getGlobalScope'); mockGetGlobalScope.mockReturnValue(mockGlobalScope); }); afterEach(() => { integrationPlugin.reset(); - if (mockGetGlobalScope) { - mockGetGlobalScope.mockRestore(); - } + jest.clearAllMocks(); }); describe('when consent is granted', () => { @@ -245,7 +243,9 @@ describe('ConsentAwareExposureHandler', () => { }); test('should not throw error when no integration exists', () => { - expect(() => handler.setConsentStatus(ConsentStatus.PENDING)).not.toThrow(); + expect(() => + handler.setConsentStatus(ConsentStatus.PENDING), + ).not.toThrow(); }); }); @@ -278,7 +278,9 @@ describe('ConsentAwareExposureHandler', () => { }, }; - expect(() => errorGlobalScope.experimentIntegration.track(event)).not.toThrow(); + expect(() => + errorGlobalScope.experimentIntegration.track(event), + ).not.toThrow(); }); }); }); diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 45a99219..7841688d 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1377,7 +1377,6 @@ describe('initializeExperiment', () => { }); }); - describe('marketing cookie with different consent status', () => { let mockCampaignParser: any; let mockCookieStorage: any; @@ -1637,16 +1636,13 @@ describe('initializeExperiment', () => { client.start(); - // Should not track immediately when consent is pending expect(originalTrackSpy).not.toHaveBeenCalled(); - // Change consent to granted client.setConsentStatus(ConsentStatus.GRANTED); - // Should now fire all pending exposures expect(originalTrackSpy).toHaveBeenCalled(); - const trackedEvents = originalTrackSpy.mock.calls.map(call => call[0]); + const trackedEvents = originalTrackSpy.mock.calls.map((call) => call[0]); expect(trackedEvents).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -1663,7 +1659,7 @@ describe('initializeExperiment', () => { variant: 'control', }), }), - ]) + ]), ); }); @@ -1688,16 +1684,12 @@ describe('initializeExperiment', () => { client.start(); - // Should not track immediately when consent is pending expect(originalTrackSpy).not.toHaveBeenCalled(); - // Change consent to rejected client.setConsentStatus(ConsentStatus.REJECTED); - // Should not fire any exposures expect(originalTrackSpy).not.toHaveBeenCalled(); - // Even if consent is later granted, should not fire the previously pending exposures client.setConsentStatus(ConsentStatus.GRANTED); expect(originalTrackSpy).not.toHaveBeenCalled(); }); @@ -1723,20 +1715,14 @@ describe('initializeExperiment', () => { client.start(); - // Should not track immediately when consent is pending expect(originalTrackSpy).not.toHaveBeenCalled(); - // Change consent to granted client.setConsentStatus(ConsentStatus.GRANTED); - // Should fire any pending exposures (if any were created during start) - // Clear mock calls to test new exposures originalTrackSpy.mockClear(); - // Trigger a new exposure by calling exposure method directly client.getExperimentClient().exposure('test-1'); - // Should track the new exposure immediately expect(originalTrackSpy).toHaveBeenCalledTimes(1); expect(originalTrackSpy.mock.calls[0][0]).toMatchObject({ eventType: '$impression', @@ -1768,29 +1754,26 @@ describe('initializeExperiment', () => { client.start(); - // Should not track immediately when consent is pending expect(originalTrackSpy).not.toHaveBeenCalled(); - // Change to rejected - should delete pending exposures client.setConsentStatus(ConsentStatus.REJECTED); expect(originalTrackSpy).not.toHaveBeenCalled(); - // Change to pending again client.setConsentStatus(ConsentStatus.PENDING); expect(originalTrackSpy).not.toHaveBeenCalled(); - // Trigger a new exposure client.getExperimentClient().exposure('test'); expect(originalTrackSpy).not.toHaveBeenCalled(); - // Change to granted - should fire the new pending exposure client.setConsentStatus(ConsentStatus.GRANTED); expect(originalTrackSpy).toHaveBeenCalledTimes(1); }); it('should add timestamp to exposure events', () => { const mockDate = new Date('2023-01-01T00:00:00.000Z'); - const mockGetTime = jest.spyOn(Date.prototype, 'getTime').mockReturnValue(mockDate.getTime()); + const mockGetTime = jest + .spyOn(Date.prototype, 'getTime') + .mockReturnValue(mockDate.getTime()); const mockGlobal = newMockGlobal({ experimentConfig: { @@ -1856,10 +1839,12 @@ describe('initializeExperiment', () => { JSON.stringify(DEFAULT_PAGE_OBJECTS), ); - // Should not throw an error expect(() => client.start()).not.toThrow(); - expect(consoleWarnSpy).toHaveBeenCalledWith('Failed to track event:', expect.any(Error)); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Failed to track event:', + expect.any(Error), + ); consoleWarnSpy.mockRestore(); }); @@ -1896,10 +1881,14 @@ describe('initializeExperiment', () => { client.start(); - // Should not throw an error when changing consent status - expect(() => client.setConsentStatus(ConsentStatus.GRANTED)).not.toThrow(); + expect(() => + client.setConsentStatus(ConsentStatus.GRANTED), + ).not.toThrow(); - expect(consoleWarnSpy).toHaveBeenCalledWith('Failed to track pending event:', expect.any(Error)); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Failed to track pending event:', + expect.any(Error), + ); consoleWarnSpy.mockRestore(); }); }); From 18adb44710e29504393191cedd149da0c564114d Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Tue, 7 Oct 2025 09:55:18 -0700 Subject: [PATCH 12/16] fix comment --- .../experiment-tag/test/consent-aware-exposure-handler.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts index da4d5a59..c8728a81 100644 --- a/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts +++ b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts @@ -238,6 +238,8 @@ describe('ConsentAwareExposureHandler', () => { describe('without experiment integration', () => { beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mockGetGlobalScope.mockReturnValue({}); handler = new ConsentAwareExposureHandler(ConsentStatus.GRANTED); }); From 78be13732433d71dcbb10bb7a43d212cbba29858 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Tue, 7 Oct 2025 11:49:09 -0700 Subject: [PATCH 13/16] fix init --- packages/experiment-tag/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/experiment-tag/src/index.ts b/packages/experiment-tag/src/index.ts index f58648d0..12d49ea0 100644 --- a/packages/experiment-tag/src/index.ts +++ b/packages/experiment-tag/src/index.ts @@ -14,7 +14,7 @@ export const initialize = ( config: WebExperimentConfig, ): void => { if ( - getGlobalScope()?.experimentConfig.consentOptions.status === + getGlobalScope()?.experimentConfig?.consentOptions?.status === ConsentStatus.REJECTED ) { return; From 40c6cd1f6fd93c23a35c367d485ac2a56db84ccf Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:00:23 -0700 Subject: [PATCH 14/16] nit: formatting --- packages/experiment-tag/src/experiment.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index c2597e3b..eb6dd79f 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -273,7 +273,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.globalScope.experimentIntegration.type = 'integration'; this.consentAwareExposureHandler.wrapExperimentIntegrationTrack(); this.experimentClient.addPlugin(this.globalScope.experimentIntegration); - this.experimentClient.setUser(user); if (!this.isRemoteBlocking) { From 9b09523219be373e42f9be828b295de202d58714 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:04:44 -0700 Subject: [PATCH 15/16] simplify --- .../src/exposure/consent-aware-exposure-handler.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts index b5216414..2c58c5d1 100644 --- a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts +++ b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts @@ -13,7 +13,6 @@ export class ConsentAwareExposureHandler { private pendingEvents: ExperimentEvent[] = []; private consentStatus: ConsentStatus = ConsentStatus.PENDING; private originalTrack: ((event: ExperimentEvent) => boolean) | null = null; - private isWrapped = false; constructor(initialConsentStatus: ConsentStatus) { this.consentStatus = initialConsentStatus; @@ -24,10 +23,6 @@ export class ConsentAwareExposureHandler { * Prevents nested wrapping by checking if already wrapped */ public wrapExperimentIntegrationTrack(): void { - if (this.isWrapped) { - return; - } - const globalScope = getGlobalScope(); const experimentIntegration = globalScope?.experimentIntegration as IntegrationPlugin; @@ -42,7 +37,6 @@ export class ConsentAwareExposureHandler { const wrappedTrack = this.createConsentAwareTrack(this.originalTrack); (wrappedTrack as any).__isConsentAwareWrapped = true; experimentIntegration.track = wrappedTrack; - this.isWrapped = true; } } From 4050b46357e9c6a462406a452de1c03c7f372e32 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 16 Oct 2025 11:11:07 -0700 Subject: [PATCH 16/16] use date.now --- .../src/exposure/consent-aware-exposure-handler.ts | 2 +- packages/experiment-tag/src/types.ts | 1 - packages/experiment-tag/test/experiment.test.ts | 6 +++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts index 2c58c5d1..8521a75b 100644 --- a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts +++ b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts @@ -57,7 +57,7 @@ export class ConsentAwareExposureHandler { ) { return (event: ExperimentEvent): boolean => { if (event?.eventProperties) { - event.eventProperties.time = new Date().getTime(); + event.eventProperties.time = Date.now(); } try { if (this.consentStatus === ConsentStatus.PENDING) { diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index dc426eb0..1282e5e5 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -2,7 +2,6 @@ import { EvaluationCondition } from '@amplitude/experiment-core'; import { ExperimentConfig, ExperimentUser, - Variant, } from '@amplitude/experiment-js-client'; import { ExperimentClient, Variants } from '@amplitude/experiment-js-client'; diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 7841688d..b4f15686 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1771,8 +1771,8 @@ describe('initializeExperiment', () => { it('should add timestamp to exposure events', () => { const mockDate = new Date('2023-01-01T00:00:00.000Z'); - const mockGetTime = jest - .spyOn(Date.prototype, 'getTime') + const mockNow = jest + .spyOn(Date, 'now') .mockReturnValue(mockDate.getTime()); const mockGlobal = newMockGlobal({ @@ -1805,7 +1805,7 @@ describe('initializeExperiment', () => { }), }); - mockGetTime.mockRestore(); + mockNow.mockRestore(); }); it('should handle exposure tracking errors gracefully', () => {