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/experiment.ts b/packages/experiment-tag/src/experiment.ts index 962031fc..eb6dd79f 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'; @@ -104,6 +105,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { status: ConsentStatus.GRANTED, }; private storage: ConsentAwareStorage; + private consentAwareExposureHandler: ConsentAwareExposureHandler; constructor( apiKey: string, @@ -134,6 +136,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.storage = new ConsentAwareStorage(this.consentOptions.status); + this.consentAwareExposureHandler = new ConsentAwareExposureHandler( + this.consentOptions.status, + ); + this.initialFlags.forEach((flag: EvaluationFlag) => { const { key, variants, metadata = {} } = flag; @@ -265,6 +271,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { ); } this.globalScope.experimentIntegration.type = 'integration'; + this.consentAwareExposureHandler.wrapExperimentIntegrationTrack(); this.experimentClient.addPlugin(this.globalScope.experimentIntegration); this.experimentClient.setUser(user); @@ -535,6 +542,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { public setConsentStatus(consentStatus: ConsentStatus) { this.consentOptions.status = consentStatus; this.storage.setConsentStatus(consentStatus); + this.consentAwareExposureHandler.setConsentStatus(consentStatus); } private async fetchRemoteFlags() { diff --git a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts new file mode 100644 index 00000000..8521a75b --- /dev/null +++ b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts @@ -0,0 +1,102 @@ +import { getGlobalScope } from '@amplitude/experiment-core'; +import { + ExperimentEvent, + IntegrationPlugin, +} from '@amplitude/experiment-js-client'; + +import { ConsentStatus } from '../types'; + +/** + * Consent-aware exposure handler that wraps window.experimentIntegration.track + */ +export class ConsentAwareExposureHandler { + private pendingEvents: ExperimentEvent[] = []; + private consentStatus: ConsentStatus = ConsentStatus.PENDING; + private originalTrack: ((event: ExperimentEvent) => boolean) | null = null; + + constructor(initialConsentStatus: ConsentStatus) { + this.consentStatus = initialConsentStatus; + } + + /** + * Wrap the experimentIntegration.track method with consent-aware logic + * Prevents nested wrapping by checking if already wrapped + */ + public wrapExperimentIntegrationTrack(): void { + const globalScope = getGlobalScope(); + const experimentIntegration = + globalScope?.experimentIntegration as IntegrationPlugin; + if (experimentIntegration?.track) { + if (this.isTrackMethodWrapped(experimentIntegration.track)) { + return; + } + + this.originalTrack = experimentIntegration.track.bind( + experimentIntegration, + ); + const wrappedTrack = this.createConsentAwareTrack(this.originalTrack); + (wrappedTrack as any).__isConsentAwareWrapped = true; + experimentIntegration.track = wrappedTrack; + } + } + + /** + * 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 + */ + private createConsentAwareTrack( + originalTrack: (event: ExperimentEvent) => boolean, + ) { + return (event: ExperimentEvent): boolean => { + if (event?.eventProperties) { + event.eventProperties.time = Date.now(); + } + 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; + } + }; + } + + /** + * Set the consent status and handle pending events accordingly + */ + public setConsentStatus(consentStatus: ConsentStatus): void { + const previousStatus = this.consentStatus; + this.consentStatus = consentStatus; + + if (previousStatus === ConsentStatus.PENDING) { + if (consentStatus === ConsentStatus.GRANTED) { + 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/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; 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/consent-aware-exposure-handler.test.ts b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts new file mode 100644 index 00000000..c8728a81 --- /dev/null +++ b/packages/experiment-tag/test/consent-aware-exposure-handler.test.ts @@ -0,0 +1,288 @@ +import * as experimentCore from '@amplitude/experiment-core'; +import { + ExperimentEvent, + IntegrationPlugin, +} from '@amplitude/experiment-js-client'; +import { ConsentAwareExposureHandler } from 'src/exposure/consent-aware-exposure-handler'; +import { ConsentStatus } from 'src/types'; + +class TestIntegrationPlugin implements IntegrationPlugin { + public trackedEvents: ExperimentEvent[] = []; + public trackCount = 0; + public type = 'integration' as const; + public originalTrack: (event: ExperimentEvent) => boolean; + + constructor() { + this.originalTrack = this.track.bind(this); + } + + track(event: ExperimentEvent): boolean { + this.trackCount += 1; + this.trackedEvents.push(event); + return true; + } + + getUser() { + return { + user_id: 'test-user', + device_id: 'test-device', + }; + } + + reset(): void { + this.trackedEvents = []; + this.trackCount = 0; + // Restore original track method + this.track = this.originalTrack; + } +} + +describe('ConsentAwareExposureHandler', () => { + const mockGetGlobalScope = jest.spyOn(experimentCore, 'getGlobalScope'); + let integrationPlugin: TestIntegrationPlugin; + let handler: ConsentAwareExposureHandler; + let mockGlobalScope: any; + + beforeEach(() => { + integrationPlugin = new TestIntegrationPlugin(); + mockGlobalScope = { + experimentIntegration: integrationPlugin, + }; + mockGetGlobalScope.mockReturnValue(mockGlobalScope); + }); + + afterEach(() => { + integrationPlugin.reset(); + jest.clearAllMocks(); + }); + + describe('when consent is granted', () => { + beforeEach(() => { + handler = new ConsentAwareExposureHandler(ConsentStatus.GRANTED); + handler.wrapExperimentIntegrationTrack(); + }); + + test('should track events immediately', () => { + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'test-flag', + variant: 'test-variant', + }, + }; + + // The track method should now be wrapped + mockGlobalScope.experimentIntegration.track(event); + + expect(integrationPlugin.trackCount).toBe(1); + expect(integrationPlugin.trackedEvents).toEqual([event]); + }); + + 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' }, + }; + + mockGlobalScope.experimentIntegration.track(event1); + mockGlobalScope.experimentIntegration.track(event2); + + expect(integrationPlugin.trackCount).toBe(2); + expect(integrationPlugin.trackedEvents).toEqual([event1, event2]); + }); + }); + + describe('when consent is pending', () => { + beforeEach(() => { + handler = new ConsentAwareExposureHandler(ConsentStatus.PENDING); + handler.wrapExperimentIntegrationTrack(); + }); + + test('should not track events immediately', () => { + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'test-flag', + variant: 'test-variant', + }, + }; + + mockGlobalScope.experimentIntegration.track(event); + + expect(integrationPlugin.trackCount).toBe(0); + expect(integrationPlugin.trackedEvents).toEqual([]); + }); + + 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' }, + }; + + mockGlobalScope.experimentIntegration.track(event1); + mockGlobalScope.experimentIntegration.track(event2); + + expect(integrationPlugin.trackCount).toBe(0); + expect(integrationPlugin.trackedEvents).toEqual([]); + }); + + 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' }, + }; + + mockGlobalScope.experimentIntegration.track(event1); + mockGlobalScope.experimentIntegration.track(event2); + + handler.setConsentStatus(ConsentStatus.GRANTED); + + expect(integrationPlugin.trackCount).toBe(2); + expect(integrationPlugin.trackedEvents).toEqual([event1, event2]); + }); + + 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' }, + }; + + mockGlobalScope.experimentIntegration.track(event1); + handler.setConsentStatus(ConsentStatus.GRANTED); + mockGlobalScope.experimentIntegration.track(event2); + + expect(integrationPlugin.trackCount).toBe(2); + expect(integrationPlugin.trackedEvents).toEqual([event1, event2]); + }); + + 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' }, + }; + + mockGlobalScope.experimentIntegration.track(event1); + mockGlobalScope.experimentIntegration.track(event2); + + handler.setConsentStatus(ConsentStatus.REJECTED); + + expect(integrationPlugin.trackCount).toBe(0); + expect(integrationPlugin.trackedEvents).toEqual([]); + + handler.setConsentStatus(ConsentStatus.GRANTED); + expect(integrationPlugin.trackCount).toBe(0); + expect(integrationPlugin.trackedEvents).toEqual([]); + }); + }); + + describe('when consent is rejected', () => { + beforeEach(() => { + handler = new ConsentAwareExposureHandler(ConsentStatus.REJECTED); + handler.wrapExperimentIntegrationTrack(); + }); + + test('should not track events', () => { + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'test-flag', + variant: 'test-variant', + }, + }; + + mockGlobalScope.experimentIntegration.track(event); + + expect(integrationPlugin.trackCount).toBe(0); + expect(integrationPlugin.trackedEvents).toEqual([]); + }); + + test('should track events when consent becomes granted', () => { + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'test-flag', + variant: 'test-variant', + }, + }; + + mockGlobalScope.experimentIntegration.track(event); + expect(integrationPlugin.trackCount).toBe(0); + + handler.setConsentStatus(ConsentStatus.GRANTED); + mockGlobalScope.experimentIntegration.track(event); + + expect(integrationPlugin.trackCount).toBe(1); + expect(integrationPlugin.trackedEvents).toEqual([event]); + }); + }); + + describe('without experiment integration', () => { + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + mockGetGlobalScope.mockReturnValue({}); + handler = new ConsentAwareExposureHandler(ConsentStatus.GRANTED); + }); + + test('should not throw error when no integration exists', () => { + expect(() => + handler.setConsentStatus(ConsentStatus.PENDING), + ).not.toThrow(); + }); + }); + + describe('error handling', () => { + let errorIntegrationPlugin: IntegrationPlugin; + let errorGlobalScope: any; + + beforeEach(() => { + errorIntegrationPlugin = { + type: 'integration' as const, + track: () => { + throw new Error('Tracking failed'); + }, + getUser: () => ({ user_id: 'test', device_id: 'test' }), + }; + errorGlobalScope = { + experimentIntegration: errorIntegrationPlugin, + }; + mockGetGlobalScope.mockReturnValue(errorGlobalScope); + handler = new ConsentAwareExposureHandler(ConsentStatus.GRANTED); + handler.wrapExperimentIntegrationTrack(); + }); + + test('should handle errors gracefully when original track throws', () => { + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'test-flag', + variant: 'test-variant', + }, + }; + + 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 1cdc7263..b4f15686 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1506,6 +1506,393 @@ 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(); + + expect(originalTrackSpy).not.toHaveBeenCalled(); + + client.setConsentStatus(ConsentStatus.GRANTED); + + 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(); + + expect(originalTrackSpy).not.toHaveBeenCalled(); + + client.setConsentStatus(ConsentStatus.REJECTED); + + expect(originalTrackSpy).not.toHaveBeenCalled(); + + 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(); + + expect(originalTrackSpy).not.toHaveBeenCalled(); + + client.setConsentStatus(ConsentStatus.GRANTED); + + originalTrackSpy.mockClear(); + + client.getExperimentClient().exposure('test-1'); + + 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(); + + expect(originalTrackSpy).not.toHaveBeenCalled(); + + client.setConsentStatus(ConsentStatus.REJECTED); + expect(originalTrackSpy).not.toHaveBeenCalled(); + + client.setConsentStatus(ConsentStatus.PENDING); + expect(originalTrackSpy).not.toHaveBeenCalled(); + + client.getExperimentClient().exposure('test'); + expect(originalTrackSpy).not.toHaveBeenCalled(); + + 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 mockNow = jest + .spyOn(Date, 'now') + .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(), + }), + }); + + mockNow.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), + ); + + 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(); + + 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 = {};