From 5cdad6c5d1037ffa8f0983985e7124bfe27a81e5 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 1 Oct 2025 13:40:19 -0700 Subject: [PATCH 01/20] add ConsentAwareStorage --- packages/experiment-tag/src/experiment.ts | 50 ++++++++---- packages/experiment-tag/src/index.ts | 8 +- packages/experiment-tag/src/types.ts | 13 +++ packages/experiment-tag/src/util/storage.ts | 88 +++++++++++++++++++++ 4 files changed, 141 insertions(+), 18 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index caf67cf1..681789e4 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -19,6 +19,8 @@ import { MessageBus } from './message-bus'; import { showPreviewModeModal } from './preview/preview'; import { PageChangeEvent, SubscriptionManager } from './subscriptions'; import { + ConsentOptions, + ConsentStatus, Defaults, WebExperimentClient, WebExperimentConfig, @@ -37,11 +39,7 @@ import { setMarketingCookie } from './util/cookie'; import { getInjectUtils } from './util/inject-utils'; import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger'; import { patchRemoveChild } from './util/patch'; -import { - getStorageItem, - setStorageItem, - removeStorageItem, -} from './util/storage'; +import { ConsentAwareStorage } from './util/storage'; import { getUrlParams, removeQueryParams, @@ -103,6 +101,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // Preview mode is set by url params, postMessage or session storage, not chrome extension isPreviewMode = false; previewFlags: Record = {}; + private consentOptions: ConsentOptions = { + status: ConsentStatus.GRANTED, + }; + private storage: ConsentAwareStorage; constructor( apiKey: string, @@ -127,6 +129,13 @@ export class DefaultWebExperimentClient implements WebExperimentClient { ...(this.globalScope.experimentConfig ?? {}), }; + if (this.config.consentOptions) { + this.consentOptions = this.config.consentOptions; + } + + // Initialize consent-aware storage + this.storage = new ConsentAwareStorage(this.consentOptions.status); + this.initialFlags.forEach((flag: EvaluationFlag) => { const { key, variants, metadata = {} } = flag; @@ -175,7 +184,8 @@ export class DefaultWebExperimentClient implements WebExperimentClient { const urlParams = getUrlParams(); this.isVisualEditorMode = urlParams[VISUAL_EDITOR_PARAM] === 'true' || - getStorageItem('sessionStorage', VISUAL_EDITOR_SESSION_KEY) !== null; + this.storage.getItem('sessionStorage', VISUAL_EDITOR_SESSION_KEY) !== + null; this.subscriptionManager = new SubscriptionManager( this, this.messageBus, @@ -210,7 +220,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { const experimentStorageName = `EXP_${this.apiKey.slice(0, 10)}`; const user = - getStorageItem( + this.storage.getItem( 'localStorage', experimentStorageName, ) || {}; @@ -222,10 +232,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient { if (!user.web_exp_id) { user.web_exp_id = user.device_id || UUID(); delete user.device_id; - setStorageItem('localStorage', experimentStorageName, user); + this.storage.setItem('localStorage', experimentStorageName, user); } else if (user.web_exp_id && user.device_id) { delete user.device_id; - setStorageItem('localStorage', experimentStorageName, user); + this.storage.setItem('localStorage', experimentStorageName, user); } // evaluate variants for page targeting @@ -524,6 +534,12 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.customRedirectHandler = handler; } + public setConsentStatus(consentStatus: ConsentStatus) { + this.consentOptions.status = consentStatus; + // Update storage consent status to handle persistence behavior + this.storage.setConsentStatus(consentStatus); + } + private async fetchRemoteFlags() { try { // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -799,16 +815,16 @@ export class DefaultWebExperimentClient implements WebExperimentClient { const redirectStorageKey = `EXP_${this.apiKey.slice(0, 10)}_REDIRECT`; // Store the current flag and variant for exposure tracking after redirect const storedRedirects = - getStorageItem('sessionStorage', redirectStorageKey) || {}; + this.storage.getItem('sessionStorage', redirectStorageKey) || {}; storedRedirects[flagKey] = { redirectUrl, variant }; - setStorageItem('sessionStorage', redirectStorageKey, storedRedirects); + this.storage.setItem('sessionStorage', redirectStorageKey, storedRedirects); } private fireStoredRedirectImpressions() { // Check for stored redirects and process them const redirectStorageKey = `EXP_${this.apiKey.slice(0, 10)}_REDIRECT`; const storedRedirects = - getStorageItem('sessionStorage', redirectStorageKey) || {}; + this.storage.getItem('sessionStorage', redirectStorageKey) || {}; // If we have stored redirects, track exposures for them if (Object.keys(storedRedirects).length > 0) { @@ -833,7 +849,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // track exposure with timeout of 500ms this.globalScope.setTimeout(() => { const redirects = - getStorageItem('sessionStorage', redirectStorageKey) || {}; + this.storage.getItem('sessionStorage', redirectStorageKey) || {}; for (const storedFlagKey in redirects) { this.exposureWithDedupe( storedFlagKey, @@ -841,10 +857,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient { true, ); } - removeStorageItem('sessionStorage', redirectStorageKey); + this.storage.removeItem('sessionStorage', redirectStorageKey); }, 500); } else { - removeStorageItem('sessionStorage', redirectStorageKey); + this.storage.removeItem('sessionStorage', redirectStorageKey); } } @@ -857,7 +873,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } }); - setStorageItem('sessionStorage', PREVIEW_MODE_SESSION_KEY, { + this.storage.setItem('sessionStorage', PREVIEW_MODE_SESSION_KEY, { previewFlags: this.previewFlags, }); const previewParamsToRemove = [ @@ -875,7 +891,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // if in preview mode, listen for ForceVariant messages WindowMessenger.setup(); } else { - const previewState: PreviewState | null = getStorageItem( + const previewState: PreviewState | null = this.storage.getItem( 'sessionStorage', PREVIEW_MODE_SESSION_KEY, ); diff --git a/packages/experiment-tag/src/index.ts b/packages/experiment-tag/src/index.ts index 3ddf260d..fc3464ac 100644 --- a/packages/experiment-tag/src/index.ts +++ b/packages/experiment-tag/src/index.ts @@ -3,7 +3,7 @@ import { getGlobalScope } from '@amplitude/experiment-core'; import { DefaultWebExperimentClient } from './experiment'; import { HttpClient } from './preview/http'; import { SdkPreviewApi } from './preview/preview-api'; -import { WebExperimentConfig } from './types'; +import { ConsentStatus, WebExperimentConfig } from './types'; import { applyAntiFlickerCss } from './util/anti-flicker'; import { isPreviewMode } from './util/url'; @@ -13,6 +13,12 @@ export const initialize = ( pageObjects: string, config: WebExperimentConfig, ): void => { + if ( + getGlobalScope()?.globalScope.experimentConfig.consentOptions.status === + ConsentStatus.REJECTED + ) { + return; + } const shouldFetchConfigs = isPreviewMode() || getGlobalScope()?.WebExperiment.injectedByExtension; diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index aa7b0b2f..521847e6 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -43,6 +43,16 @@ export type PageObject = { export type PageObjects = { [flagKey: string]: { [id: string]: PageObject } }; +export enum ConsentStatus { + REJECTED = 0, + GRANTED = 1, + PENDING = 2, +} + +export type ConsentOptions = { + status: ConsentStatus; +}; + export interface WebExperimentConfig extends ExperimentConfig { /** * Determines whether the default implementation for handling navigation will be used @@ -51,6 +61,7 @@ export interface WebExperimentConfig extends ExperimentConfig { * 2. Custom handling of navigation {@link setRedirectHandler} should be implemented such that variant actions applied on the site reflect the latest context */ useDefaultNavigationHandler?: boolean; + consentOptions?: ConsentOptions; } export const Defaults: WebExperimentConfig = { @@ -79,6 +90,8 @@ export interface WebExperimentClient { getActivePages(): PageObjects; setRedirectHandler(handler: (url: string) => void): void; + + setConsentStatus(consentStatus: ConsentStatus): void; } export type WebExperimentUser = { diff --git a/packages/experiment-tag/src/util/storage.ts b/packages/experiment-tag/src/util/storage.ts index 51c1cbf3..914e4d82 100644 --- a/packages/experiment-tag/src/util/storage.ts +++ b/packages/experiment-tag/src/util/storage.ts @@ -1,5 +1,7 @@ import { getGlobalScope } from '@amplitude/experiment-core'; +import { ConsentStatus } from '../types'; + export type StorageType = 'localStorage' | 'sessionStorage'; /** @@ -66,3 +68,89 @@ const getStorage = (storageType: StorageType): Storage | null => { } return globalScope[storageType]; }; + +/** + * Consent-aware storage manager that handles persistence based on consent status + */ +export class ConsentAwareStorage { + private inMemoryStorage: Map> = new Map(); + private consentStatus: ConsentStatus = ConsentStatus.PENDING; + + constructor(initialConsentStatus: ConsentStatus) { + this.consentStatus = initialConsentStatus; + } + + /** + * Set the consent status and handle persistence accordingly + */ + public setConsentStatus(consentStatus: ConsentStatus): void { + const previousStatus = this.consentStatus; + this.consentStatus = consentStatus; + + if (previousStatus === ConsentStatus.PENDING) { + if (consentStatus === ConsentStatus.GRANTED) { + for (const [ + storageType, + storageMap, + ] of this.inMemoryStorage.entries()) { + for (const [key, value] of storageMap.entries()) { + try { + const jsonString = JSON.stringify(value); + getStorage(storageType)?.setItem(key, jsonString); + } catch (error) { + console.warn(`Failed to persist data for key ${key}:`, error); + } + } + } + this.inMemoryStorage.clear(); + } + } + } + + /** + * Get a JSON value from storage with consent awareness + */ + public getItem(storageType: StorageType, key: string): T | null { + const storageMap = this.inMemoryStorage.get(storageType); + if (storageMap && storageMap.has(key)) { + return storageMap.get(key) as T; + } + + if (this.consentStatus === ConsentStatus.GRANTED) { + return getStorageItem(storageType, key); + } + + return null; + } + + /** + * Set a JSON value in storage with consent awareness + */ + public setItem(storageType: StorageType, key: string, value: unknown): void { + if (this.consentStatus === ConsentStatus.PENDING) { + if (!this.inMemoryStorage.has(storageType)) { + this.inMemoryStorage.set(storageType, new Map()); + } + this.inMemoryStorage.get(storageType)?.set(key, value); + } else if (this.consentStatus === ConsentStatus.GRANTED) { + setStorageItem(storageType, key, value); + } + } + + /** + * Remove a value from storage with consent awareness + */ + public removeItem(storageType: StorageType, key: string): void { + const storageMap = this.inMemoryStorage.get(storageType); + if (storageMap) { + storageMap.delete(key); + if (storageMap.size === 0) { + this.inMemoryStorage.delete(storageType); + } + } + + if (this.consentStatus === ConsentStatus.GRANTED) { + removeStorageItem(storageType, key); + } + } +} From c36d0fe7c891cb83ce5b7dee21df4e53a420f70a Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:16:11 -0700 Subject: [PATCH 02/20] add test cases --- packages/experiment-tag/src/experiment.ts | 2 +- .../src/{util => storage}/storage.ts | 32 +- packages/experiment-tag/src/util/messenger.ts | 2 +- packages/experiment-tag/src/util/url.ts | 2 +- packages/experiment-tag/test/storage.test.ts | 455 ++++++++++++++++++ 5 files changed, 471 insertions(+), 22 deletions(-) rename packages/experiment-tag/src/{util => storage}/storage.ts (86%) create mode 100644 packages/experiment-tag/test/storage.test.ts diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 681789e4..e1af7ffc 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -39,7 +39,7 @@ import { setMarketingCookie } from './util/cookie'; 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 { ConsentAwareStorage } from './storage/storage'; import { getUrlParams, removeQueryParams, diff --git a/packages/experiment-tag/src/util/storage.ts b/packages/experiment-tag/src/storage/storage.ts similarity index 86% rename from packages/experiment-tag/src/util/storage.ts rename to packages/experiment-tag/src/storage/storage.ts index 914e4d82..070245ed 100644 --- a/packages/experiment-tag/src/util/storage.ts +++ b/packages/experiment-tag/src/storage/storage.ts @@ -84,26 +84,20 @@ export class ConsentAwareStorage { * Set the consent status and handle persistence accordingly */ public setConsentStatus(consentStatus: ConsentStatus): void { - const previousStatus = this.consentStatus; this.consentStatus = consentStatus; - if (previousStatus === ConsentStatus.PENDING) { - if (consentStatus === ConsentStatus.GRANTED) { - for (const [ - storageType, - storageMap, - ] of this.inMemoryStorage.entries()) { - for (const [key, value] of storageMap.entries()) { - try { - const jsonString = JSON.stringify(value); - getStorage(storageType)?.setItem(key, jsonString); - } catch (error) { - console.warn(`Failed to persist data for key ${key}:`, error); - } + if (consentStatus === ConsentStatus.GRANTED) { + for (const [storageType, storageMap] of this.inMemoryStorage.entries()) { + for (const [key, value] of storageMap.entries()) { + try { + const jsonString = JSON.stringify(value); + getStorage(storageType)?.setItem(key, jsonString); + } catch (error) { + console.warn(`Failed to persist data for key ${key}:`, error); } } - this.inMemoryStorage.clear(); } + this.inMemoryStorage.clear(); } } @@ -111,15 +105,15 @@ export class ConsentAwareStorage { * Get a JSON value from storage with consent awareness */ public getItem(storageType: StorageType, key: string): T | null { + if (this.consentStatus === ConsentStatus.GRANTED) { + return getStorageItem(storageType, key); + } + const storageMap = this.inMemoryStorage.get(storageType); if (storageMap && storageMap.has(key)) { return storageMap.get(key) as T; } - if (this.consentStatus === ConsentStatus.GRANTED) { - return getStorageItem(storageType, key); - } - return null; } diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index 97dd5db9..1cf0bb19 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -1,6 +1,6 @@ import { getGlobalScope } from '@amplitude/experiment-core'; -import { getStorageItem } from './storage'; +import { getStorageItem } from '../storage/storage'; interface VisualEditorSession { injectSrc: string; diff --git a/packages/experiment-tag/src/util/url.ts b/packages/experiment-tag/src/util/url.ts index 743f2cbc..501b8835 100644 --- a/packages/experiment-tag/src/util/url.ts +++ b/packages/experiment-tag/src/util/url.ts @@ -3,7 +3,7 @@ import { getGlobalScope } from '@amplitude/experiment-core'; import { PREVIEW_MODE_PARAM, PREVIEW_MODE_SESSION_KEY } from '../experiment'; import { PreviewState } from '../types'; -import { getStorageItem } from './storage'; +import { getStorageItem } from '../storage/storage'; export const getUrlParams = (): Record => { const globalScope = getGlobalScope(); diff --git a/packages/experiment-tag/test/storage.test.ts b/packages/experiment-tag/test/storage.test.ts new file mode 100644 index 00000000..dd4bb8d4 --- /dev/null +++ b/packages/experiment-tag/test/storage.test.ts @@ -0,0 +1,455 @@ +import * as coreUtil from '@amplitude/experiment-core'; + +import { ConsentAwareStorage } from '../src/storage/storage'; +import { ConsentStatus } from '../src/types'; + +// Mock the getGlobalScope function +const spyGetGlobalScope = jest.spyOn(coreUtil, 'getGlobalScope'); + +describe('ConsentAwareStorage', () => { + let mockGlobal: any; + let storage: ConsentAwareStorage; + + const createStorageMock = () => { + let store: Record = {}; + return { + getItem: jest.fn((key: string) => store[key] || null), + setItem: jest.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: jest.fn((key: string) => { + delete store[key]; + }), + clear: jest.fn(() => { + store = {}; + }), + length: jest.fn(() => Object.keys(store).length), + key: jest.fn((index: number) => Object.keys(store)[index] || null), + }; + }; + + beforeEach(() => { + mockGlobal = { + localStorage: createStorageMock(), + sessionStorage: createStorageMock(), + }; + spyGetGlobalScope.mockReturnValue(mockGlobal); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with PENDING consent status', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + expect(storage).toBeDefined(); + }); + + it('should initialize with GRANTED consent status', () => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + expect(storage).toBeDefined(); + }); + + it('should initialize with REJECTED consent status', () => { + storage = new ConsentAwareStorage(ConsentStatus.REJECTED); + expect(storage).toBeDefined(); + }); + }); + + describe('setItem with PENDING consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + }); + + it('should store data in memory for localStorage', () => { + const testData = { key: 'value', number: 42 }; + storage.setItem('localStorage', 'testKey', testData); + + // Should not call actual storage + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + + // Should be retrievable from memory + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + + it('should store data in memory for sessionStorage', () => { + const testData = { key: 'value', array: [1, 2, 3] }; + storage.setItem('sessionStorage', 'testKey', testData); + + // Should not call actual storage + expect(mockGlobal.sessionStorage.setItem).not.toHaveBeenCalled(); + + // Should be retrievable from memory + const retrieved = storage.getItem('sessionStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + + it('should handle multiple keys in the same storage type', () => { + storage.setItem('localStorage', 'key1', 'value1'); + storage.setItem('localStorage', 'key2', 'value2'); + + expect(storage.getItem('localStorage', 'key1')).toBe('value1'); + expect(storage.getItem('localStorage', 'key2')).toBe('value2'); + }); + + it('should handle multiple storage types independently', () => { + storage.setItem('localStorage', 'key', 'localValue'); + storage.setItem('sessionStorage', 'key', 'sessionValue'); + + expect(storage.getItem('localStorage', 'key')).toBe('localValue'); + expect(storage.getItem('sessionStorage', 'key')).toBe('sessionValue'); + }); + }); + + describe('setItem with GRANTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + }); + + it('should store data directly in localStorage', () => { + const testData = { key: 'value' }; + storage.setItem('localStorage', 'testKey', testData); + + // Verify the data was stored by checking if we can retrieve it + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + + it('should store data directly in sessionStorage', () => { + const testData = { key: 'value' }; + storage.setItem('sessionStorage', 'testKey', testData); + + // Verify the data was stored by checking if we can retrieve it + const retrieved = storage.getItem('sessionStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + }); + + describe('setItem with REJECTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.REJECTED); + }); + + it('should not store data anywhere', () => { + storage.setItem('localStorage', 'testKey', { key: 'value' }); + + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + }); + + it('should not store data in sessionStorage', () => { + storage.setItem('sessionStorage', 'testKey', { key: 'value' }); + + expect(mockGlobal.sessionStorage.setItem).not.toHaveBeenCalled(); + expect(storage.getItem('sessionStorage', 'testKey')).toBeNull(); + }); + }); + + describe('getItem with PENDING consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + }); + + it('should return data from memory if available', () => { + const testData = { key: 'value' }; + storage.setItem('localStorage', 'testKey', testData); + + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); + }); + + it('should return null if not in memory and consent is pending', () => { + const retrieved = storage.getItem('localStorage', 'nonExistentKey'); + expect(retrieved).toBeNull(); + expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); + + describe('getItem with GRANTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + }); + + it('should return data from actual storage, not memory', () => { + const testData = { key: 'value' }; + + // Store data using setItem + storage.setItem('localStorage', 'testKey', testData); + + // Should be able to retrieve the same data + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + + it('should return null for non-existent keys', () => { + const retrieved = storage.getItem('localStorage', 'nonExistentKey'); + expect(retrieved).toBeNull(); + }); + }); + + describe('getItem with REJECTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.REJECTED); + }); + + it('should return null and not access actual storage', () => { + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toBeNull(); + expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); + + describe('removeItem with PENDING consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + }); + + it('should remove data from memory', () => { + storage.setItem('localStorage', 'testKey', { key: 'value' }); + expect(storage.getItem('localStorage', 'testKey')).not.toBeNull(); + + storage.removeItem('localStorage', 'testKey'); + expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + expect(mockGlobal.localStorage.removeItem).not.toHaveBeenCalled(); + }); + + it('should handle removing non-existent keys gracefully', () => { + storage.removeItem('localStorage', 'nonExistentKey'); + expect(mockGlobal.localStorage.removeItem).not.toHaveBeenCalled(); + }); + + it('should clean up empty storage maps', () => { + storage.setItem('localStorage', 'testKey', { key: 'value' }); + storage.removeItem('localStorage', 'testKey'); + + // Add another item to different storage to verify cleanup + storage.setItem('sessionStorage', 'otherKey', { other: 'value' }); + expect(storage.getItem('sessionStorage', 'otherKey')).not.toBeNull(); + }); + }); + + describe('removeItem with GRANTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + }); + + it('should remove data from both memory and actual storage', () => { + storage.setItem('localStorage', 'testKey', { key: 'value' }); + storage.removeItem('localStorage', 'testKey'); + + expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + }); + + it('should handle removing non-existent keys gracefully', () => { + storage.removeItem('localStorage', 'testKey'); + expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + }); + }); + + describe('removeItem with REJECTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.REJECTED); + }); + + it('should not remove from actual storage', () => { + storage.removeItem('localStorage', 'testKey'); + expect(mockGlobal.localStorage.removeItem).not.toHaveBeenCalled(); + }); + }); + + describe('setConsentStatus', () => { + it('should persist in-memory data when changing from PENDING to GRANTED', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + // Store data in memory + const localData = { local: 'value' }; + const sessionData = { session: 'value' }; + storage.setItem('localStorage', 'localKey', localData); + storage.setItem('sessionStorage', 'sessionKey', sessionData); + + // Change consent to granted + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Verify data is now accessible from actual storage (not memory) + expect(storage.getItem('localStorage', 'localKey')).toEqual(localData); + expect(storage.getItem('sessionStorage', 'sessionKey')).toEqual( + sessionData, + ); + }); + + it('should handle persistence errors gracefully', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + // Store data that will cause JSON.stringify to fail + const circularData: any = {}; + circularData.self = circularData; + storage.setItem('localStorage', 'circularKey', circularData); + + storage.setConsentStatus(ConsentStatus.GRANTED); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to persist data for key circularKey:', + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + it('should not persist data when changing from PENDING to REJECTED', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + storage.setItem('localStorage', 'testKey', { key: 'value' }); + storage.setConsentStatus(ConsentStatus.REJECTED); + + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + }); + + it('should not affect storage when changing from GRANTED to REJECTED', () => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + + storage.setItem('localStorage', 'testKey', { key: 'value' }); + + // Verify data is stored + expect(storage.getItem('localStorage', 'testKey')).toEqual({ + key: 'value', + }); + + storage.setConsentStatus(ConsentStatus.REJECTED); + + // After changing to REJECTED, getItem should return null because data is not in memory + // (it was stored directly to actual storage when consent was GRANTED) + expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + }); + + it('should not affect storage when changing from REJECTED to GRANTED', () => { + storage = new ConsentAwareStorage(ConsentStatus.REJECTED); + + storage.setItem('localStorage', 'testKey', { key: 'value' }); + storage.setConsentStatus(ConsentStatus.GRANTED); + + // No storage calls should have been made + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + }); + + it('should only persist on first transition from PENDING to GRANTED', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + storage.setItem('localStorage', 'testKey', { key: 'value' }); + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Clear mock calls + jest.clearAllMocks(); + + // Change status again + storage.setConsentStatus(ConsentStatus.REJECTED); + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Should not trigger persistence again + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('complex scenarios', () => { + it('should handle mixed storage types and multiple keys', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + // Store multiple items in different storage types + storage.setItem('localStorage', 'key1', { value: 1 }); + storage.setItem('localStorage', 'key2', { value: 2 }); + storage.setItem('sessionStorage', 'key1', { value: 3 }); + storage.setItem('sessionStorage', 'key2', { value: 4 }); + + // Verify all are in memory + expect(storage.getItem('localStorage', 'key1')).toEqual({ value: 1 }); + expect(storage.getItem('localStorage', 'key2')).toEqual({ value: 2 }); + expect(storage.getItem('sessionStorage', 'key1')).toEqual({ value: 3 }); + expect(storage.getItem('sessionStorage', 'key2')).toEqual({ value: 4 }); + + // Grant consent + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Verify all are still accessible after persistence + expect(storage.getItem('localStorage', 'key1')).toEqual({ value: 1 }); + expect(storage.getItem('localStorage', 'key2')).toEqual({ value: 2 }); + expect(storage.getItem('sessionStorage', 'key1')).toEqual({ value: 3 }); + expect(storage.getItem('sessionStorage', 'key2')).toEqual({ value: 4 }); + }); + + it('should handle consent status changes during active usage', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + // Store some data + storage.setItem('localStorage', 'key1', { value: 1 }); + + // Grant consent + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Store more data (should go directly to storage) + storage.setItem('localStorage', 'key2', { value: 2 }); + + // Verify both items are accessible from actual storage + expect(storage.getItem('localStorage', 'key1')).toEqual({ value: 1 }); + expect(storage.getItem('localStorage', 'key2')).toEqual({ value: 2 }); + }); + + it('should handle empty and null values correctly', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + storage.setItem('localStorage', 'nullKey', null); + storage.setItem('localStorage', 'emptyKey', ''); + storage.setItem('localStorage', 'zeroKey', 0); + storage.setItem('localStorage', 'falseKey', false); + + expect(storage.getItem('localStorage', 'nullKey')).toBeNull(); + expect(storage.getItem('localStorage', 'emptyKey')).toBe(''); + expect(storage.getItem('localStorage', 'zeroKey')).toBe(0); + expect(storage.getItem('localStorage', 'falseKey')).toBe(false); + + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Verify values are still accessible after persistence + expect(storage.getItem('localStorage', 'nullKey')).toBeNull(); + expect(storage.getItem('localStorage', 'emptyKey')).toBe(''); + expect(storage.getItem('localStorage', 'zeroKey')).toBe(0); + expect(storage.getItem('localStorage', 'falseKey')).toBe(false); + }); + }); + + describe('type safety', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + }); + + it('should handle typed getItem correctly', () => { + interface TestData { + name: string; + count: number; + } + + const testData: TestData = { name: 'test', count: 42 }; + storage.setItem('localStorage', 'typedKey', testData); + + const retrieved = storage.getItem('localStorage', 'typedKey'); + expect(retrieved).toEqual(testData); + expect(retrieved?.name).toBe('test'); + expect(retrieved?.count).toBe(42); + }); + + it('should return null for non-existent typed items', () => { + interface TestData { + name: string; + } + + const retrieved = storage.getItem( + 'localStorage', + 'nonExistentKey', + ); + expect(retrieved).toBeNull(); + }); + }); +}); From 1723edc185a95e746c7e8545935f5c5a94dffade Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:42:07 -0700 Subject: [PATCH 03/20] simplify tests, fix lint --- packages/experiment-tag/src/experiment.ts | 2 +- .../experiment-tag/src/storage/storage.ts | 8 +- packages/experiment-tag/src/util/url.ts | 3 +- packages/experiment-tag/test/storage.test.ts | 171 +----------------- 4 files changed, 7 insertions(+), 177 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index e1af7ffc..7b7e67ce 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -17,6 +17,7 @@ import mutate, { MutationController } from 'dom-mutator'; import { MessageBus } from './message-bus'; import { showPreviewModeModal } from './preview/preview'; +import { ConsentAwareStorage } from './storage/storage'; import { PageChangeEvent, SubscriptionManager } from './subscriptions'; import { ConsentOptions, @@ -39,7 +40,6 @@ import { setMarketingCookie } from './util/cookie'; import { getInjectUtils } from './util/inject-utils'; import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger'; import { patchRemoveChild } from './util/patch'; -import { ConsentAwareStorage } from './storage/storage'; import { getUrlParams, removeQueryParams, diff --git a/packages/experiment-tag/src/storage/storage.ts b/packages/experiment-tag/src/storage/storage.ts index 070245ed..759a235b 100644 --- a/packages/experiment-tag/src/storage/storage.ts +++ b/packages/experiment-tag/src/storage/storage.ts @@ -136,15 +136,15 @@ export class ConsentAwareStorage { */ public removeItem(storageType: StorageType, key: string): void { const storageMap = this.inMemoryStorage.get(storageType); + if (this.consentStatus === ConsentStatus.GRANTED) { + removeStorageItem(storageType, key); + return; + } if (storageMap) { storageMap.delete(key); if (storageMap.size === 0) { this.inMemoryStorage.delete(storageType); } } - - if (this.consentStatus === ConsentStatus.GRANTED) { - removeStorageItem(storageType, key); - } } } diff --git a/packages/experiment-tag/src/util/url.ts b/packages/experiment-tag/src/util/url.ts index 501b8835..12089f27 100644 --- a/packages/experiment-tag/src/util/url.ts +++ b/packages/experiment-tag/src/util/url.ts @@ -1,9 +1,8 @@ import { getGlobalScope } from '@amplitude/experiment-core'; import { PREVIEW_MODE_PARAM, PREVIEW_MODE_SESSION_KEY } from '../experiment'; -import { PreviewState } from '../types'; - import { getStorageItem } from '../storage/storage'; +import { PreviewState } from '../types'; export const getUrlParams = (): Record => { const globalScope = getGlobalScope(); diff --git a/packages/experiment-tag/test/storage.test.ts b/packages/experiment-tag/test/storage.test.ts index dd4bb8d4..f3703d9f 100644 --- a/packages/experiment-tag/test/storage.test.ts +++ b/packages/experiment-tag/test/storage.test.ts @@ -41,23 +41,6 @@ describe('ConsentAwareStorage', () => { jest.restoreAllMocks(); }); - describe('constructor', () => { - it('should initialize with PENDING consent status', () => { - storage = new ConsentAwareStorage(ConsentStatus.PENDING); - expect(storage).toBeDefined(); - }); - - it('should initialize with GRANTED consent status', () => { - storage = new ConsentAwareStorage(ConsentStatus.GRANTED); - expect(storage).toBeDefined(); - }); - - it('should initialize with REJECTED consent status', () => { - storage = new ConsentAwareStorage(ConsentStatus.REJECTED); - expect(storage).toBeDefined(); - }); - }); - describe('setItem with PENDING consent', () => { beforeEach(() => { storage = new ConsentAwareStorage(ConsentStatus.PENDING); @@ -67,10 +50,8 @@ describe('ConsentAwareStorage', () => { const testData = { key: 'value', number: 42 }; storage.setItem('localStorage', 'testKey', testData); - // Should not call actual storage expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); - // Should be retrievable from memory const retrieved = storage.getItem('localStorage', 'testKey'); expect(retrieved).toEqual(testData); }); @@ -79,10 +60,8 @@ describe('ConsentAwareStorage', () => { const testData = { key: 'value', array: [1, 2, 3] }; storage.setItem('sessionStorage', 'testKey', testData); - // Should not call actual storage expect(mockGlobal.sessionStorage.setItem).not.toHaveBeenCalled(); - // Should be retrievable from memory const retrieved = storage.getItem('sessionStorage', 'testKey'); expect(retrieved).toEqual(testData); }); @@ -113,7 +92,6 @@ describe('ConsentAwareStorage', () => { const testData = { key: 'value' }; storage.setItem('localStorage', 'testKey', testData); - // Verify the data was stored by checking if we can retrieve it const retrieved = storage.getItem('localStorage', 'testKey'); expect(retrieved).toEqual(testData); }); @@ -122,7 +100,6 @@ describe('ConsentAwareStorage', () => { const testData = { key: 'value' }; storage.setItem('sessionStorage', 'testKey', testData); - // Verify the data was stored by checking if we can retrieve it const retrieved = storage.getItem('sessionStorage', 'testKey'); expect(retrieved).toEqual(testData); }); @@ -177,10 +154,8 @@ describe('ConsentAwareStorage', () => { it('should return data from actual storage, not memory', () => { const testData = { key: 'value' }; - // Store data using setItem storage.setItem('localStorage', 'testKey', testData); - // Should be able to retrieve the same data const retrieved = storage.getItem('localStorage', 'testKey'); expect(retrieved).toEqual(testData); }); @@ -226,7 +201,6 @@ describe('ConsentAwareStorage', () => { storage.setItem('localStorage', 'testKey', { key: 'value' }); storage.removeItem('localStorage', 'testKey'); - // Add another item to different storage to verify cleanup storage.setItem('sessionStorage', 'otherKey', { other: 'value' }); expect(storage.getItem('sessionStorage', 'otherKey')).not.toBeNull(); }); @@ -237,7 +211,7 @@ describe('ConsentAwareStorage', () => { storage = new ConsentAwareStorage(ConsentStatus.GRANTED); }); - it('should remove data from both memory and actual storage', () => { + it('should remove data from actual storage', () => { storage.setItem('localStorage', 'testKey', { key: 'value' }); storage.removeItem('localStorage', 'testKey'); @@ -308,148 +282,5 @@ describe('ConsentAwareStorage', () => { expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); }); - - it('should not affect storage when changing from GRANTED to REJECTED', () => { - storage = new ConsentAwareStorage(ConsentStatus.GRANTED); - - storage.setItem('localStorage', 'testKey', { key: 'value' }); - - // Verify data is stored - expect(storage.getItem('localStorage', 'testKey')).toEqual({ - key: 'value', - }); - - storage.setConsentStatus(ConsentStatus.REJECTED); - - // After changing to REJECTED, getItem should return null because data is not in memory - // (it was stored directly to actual storage when consent was GRANTED) - expect(storage.getItem('localStorage', 'testKey')).toBeNull(); - }); - - it('should not affect storage when changing from REJECTED to GRANTED', () => { - storage = new ConsentAwareStorage(ConsentStatus.REJECTED); - - storage.setItem('localStorage', 'testKey', { key: 'value' }); - storage.setConsentStatus(ConsentStatus.GRANTED); - - // No storage calls should have been made - expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); - }); - - it('should only persist on first transition from PENDING to GRANTED', () => { - storage = new ConsentAwareStorage(ConsentStatus.PENDING); - - storage.setItem('localStorage', 'testKey', { key: 'value' }); - storage.setConsentStatus(ConsentStatus.GRANTED); - - // Clear mock calls - jest.clearAllMocks(); - - // Change status again - storage.setConsentStatus(ConsentStatus.REJECTED); - storage.setConsentStatus(ConsentStatus.GRANTED); - - // Should not trigger persistence again - expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); - }); - }); - - describe('complex scenarios', () => { - it('should handle mixed storage types and multiple keys', () => { - storage = new ConsentAwareStorage(ConsentStatus.PENDING); - - // Store multiple items in different storage types - storage.setItem('localStorage', 'key1', { value: 1 }); - storage.setItem('localStorage', 'key2', { value: 2 }); - storage.setItem('sessionStorage', 'key1', { value: 3 }); - storage.setItem('sessionStorage', 'key2', { value: 4 }); - - // Verify all are in memory - expect(storage.getItem('localStorage', 'key1')).toEqual({ value: 1 }); - expect(storage.getItem('localStorage', 'key2')).toEqual({ value: 2 }); - expect(storage.getItem('sessionStorage', 'key1')).toEqual({ value: 3 }); - expect(storage.getItem('sessionStorage', 'key2')).toEqual({ value: 4 }); - - // Grant consent - storage.setConsentStatus(ConsentStatus.GRANTED); - - // Verify all are still accessible after persistence - expect(storage.getItem('localStorage', 'key1')).toEqual({ value: 1 }); - expect(storage.getItem('localStorage', 'key2')).toEqual({ value: 2 }); - expect(storage.getItem('sessionStorage', 'key1')).toEqual({ value: 3 }); - expect(storage.getItem('sessionStorage', 'key2')).toEqual({ value: 4 }); - }); - - it('should handle consent status changes during active usage', () => { - storage = new ConsentAwareStorage(ConsentStatus.PENDING); - - // Store some data - storage.setItem('localStorage', 'key1', { value: 1 }); - - // Grant consent - storage.setConsentStatus(ConsentStatus.GRANTED); - - // Store more data (should go directly to storage) - storage.setItem('localStorage', 'key2', { value: 2 }); - - // Verify both items are accessible from actual storage - expect(storage.getItem('localStorage', 'key1')).toEqual({ value: 1 }); - expect(storage.getItem('localStorage', 'key2')).toEqual({ value: 2 }); - }); - - it('should handle empty and null values correctly', () => { - storage = new ConsentAwareStorage(ConsentStatus.PENDING); - - storage.setItem('localStorage', 'nullKey', null); - storage.setItem('localStorage', 'emptyKey', ''); - storage.setItem('localStorage', 'zeroKey', 0); - storage.setItem('localStorage', 'falseKey', false); - - expect(storage.getItem('localStorage', 'nullKey')).toBeNull(); - expect(storage.getItem('localStorage', 'emptyKey')).toBe(''); - expect(storage.getItem('localStorage', 'zeroKey')).toBe(0); - expect(storage.getItem('localStorage', 'falseKey')).toBe(false); - - storage.setConsentStatus(ConsentStatus.GRANTED); - - // Verify values are still accessible after persistence - expect(storage.getItem('localStorage', 'nullKey')).toBeNull(); - expect(storage.getItem('localStorage', 'emptyKey')).toBe(''); - expect(storage.getItem('localStorage', 'zeroKey')).toBe(0); - expect(storage.getItem('localStorage', 'falseKey')).toBe(false); - }); - }); - - describe('type safety', () => { - beforeEach(() => { - storage = new ConsentAwareStorage(ConsentStatus.GRANTED); - }); - - it('should handle typed getItem correctly', () => { - interface TestData { - name: string; - count: number; - } - - const testData: TestData = { name: 'test', count: 42 }; - storage.setItem('localStorage', 'typedKey', testData); - - const retrieved = storage.getItem('localStorage', 'typedKey'); - expect(retrieved).toEqual(testData); - expect(retrieved?.name).toBe('test'); - expect(retrieved?.count).toBe(42); - }); - - it('should return null for non-existent typed items', () => { - interface TestData { - name: string; - } - - const retrieved = storage.getItem( - 'localStorage', - 'nonExistentKey', - ); - expect(retrieved).toBeNull(); - }); }); }); From 05b19658aa4b17164b2cd14e9b3ab31129c720c7 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:07:31 -0700 Subject: [PATCH 04/20] consent aware marketing cookies --- packages/experiment-tag/src/experiment.ts | 7 +- .../experiment-tag/src/storage/storage.ts | 57 ++++++- packages/experiment-tag/src/types.ts | 2 +- packages/experiment-tag/src/util/cookie.ts | 19 --- .../experiment-tag/test/experiment.test.ts | 139 ++++++++++++++++ packages/experiment-tag/test/storage.test.ts | 151 +++++++++++++++++- 6 files changed, 340 insertions(+), 35 deletions(-) delete mode 100644 packages/experiment-tag/src/util/cookie.ts diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 7b7e67ce..c54b6cce 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -36,7 +36,6 @@ import { RevertVariantsOptions, } from './types'; import { applyAntiFlickerCss } from './util/anti-flicker'; -import { setMarketingCookie } from './util/cookie'; import { getInjectUtils } from './util/inject-utils'; import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger'; import { patchRemoveChild } from './util/patch'; @@ -133,7 +132,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.consentOptions = this.config.consentOptions; } - // Initialize consent-aware storage this.storage = new ConsentAwareStorage(this.consentOptions.status); this.initialFlags.forEach((flag: EvaluationFlag) => { @@ -536,7 +534,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); } @@ -594,7 +591,9 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // set previous url - relevant for SPA if redirect happens before push/replaceState is complete this.previousUrl = this.globalScope.location.href; - setMarketingCookie(this.apiKey).then(); + this.storage.setMarketingCookie(this.apiKey).catch((error) => { + console.warn('Failed to set marketing cookie:', error); + }); // perform redirection if (this.customRedirectHandler) { this.customRedirectHandler(targetUrl); diff --git a/packages/experiment-tag/src/storage/storage.ts b/packages/experiment-tag/src/storage/storage.ts index 759a235b..6bf2afc6 100644 --- a/packages/experiment-tag/src/storage/storage.ts +++ b/packages/experiment-tag/src/storage/storage.ts @@ -1,3 +1,5 @@ +import { CampaignParser, CookieStorage, MKTG } from '@amplitude/analytics-core'; +import type { Campaign } from '@amplitude/analytics-core'; import { getGlobalScope } from '@amplitude/experiment-core'; import { ConsentStatus } from '../types'; @@ -74,6 +76,7 @@ const getStorage = (storageType: StorageType): Storage | null => { */ export class ConsentAwareStorage { private inMemoryStorage: Map> = new Map(); + private inMemoryMarketingCookies: Map = new Map(); private consentStatus: ConsentStatus = ConsentStatus.PENDING; constructor(initialConsentStatus: ConsentStatus) { @@ -98,9 +101,34 @@ export class ConsentAwareStorage { } } this.inMemoryStorage.clear(); + this.persistMarketingCookies().then(); + this.inMemoryMarketingCookies.clear(); } } + /** + * Persist marketing cookies from memory to actual cookies + */ + private async persistMarketingCookies(): Promise { + for (const [ + storageKey, + campaign, + ] of this.inMemoryMarketingCookies.entries()) { + try { + const cookieStorage = new CookieStorage({ + sameSite: 'Lax', + }); + await cookieStorage.set(storageKey, campaign); + } catch (error) { + console.warn( + `Failed to persist marketing cookie for key ${storageKey}:`, + error, + ); + } + } + this.inMemoryMarketingCookies.clear(); + } + /** * Get a JSON value from storage with consent awareness */ @@ -121,13 +149,13 @@ export class ConsentAwareStorage { * Set a JSON value in storage with consent awareness */ public setItem(storageType: StorageType, key: string, value: unknown): void { - if (this.consentStatus === ConsentStatus.PENDING) { + if (this.consentStatus === ConsentStatus.GRANTED) { + setStorageItem(storageType, key, value); + } else { if (!this.inMemoryStorage.has(storageType)) { this.inMemoryStorage.set(storageType, new Map()); } this.inMemoryStorage.get(storageType)?.set(key, value); - } else if (this.consentStatus === ConsentStatus.GRANTED) { - setStorageItem(storageType, key, value); } } @@ -147,4 +175,27 @@ export class ConsentAwareStorage { } } } + + /** + * Set marketing cookie with consent awareness + * Parses current campaign data from URL and referrer, then stores it in the marketing cookie + */ + public async setMarketingCookie(apiKey: string): Promise { + try { + const parser = new CampaignParser(); + const storageKey = `AMP_${MKTG}_ORIGINAL_${apiKey.substring(0, 10)}`; + const campaign = await parser.parse(); + + if (this.consentStatus === ConsentStatus.GRANTED) { + const cookieStorage = new CookieStorage({ + sameSite: 'Lax', + }); + await cookieStorage.set(storageKey, campaign); + } else { + this.inMemoryMarketingCookies.set(storageKey, campaign); + } + } catch (error) { + console.warn('Failed to set marketing cookie:', error); + } + } } diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index 521847e6..dc426eb0 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -55,7 +55,7 @@ export type ConsentOptions = { export interface WebExperimentConfig extends ExperimentConfig { /** - * Determines whether the default implementation for handling navigation will be used + * Determines whether the default implementation for handling navigation will be used * If this is set to false, for single-page applications: * 1. The variant actions applied will be based on the context (user, page URL) when the web experiment script was loaded * 2. Custom handling of navigation {@link setRedirectHandler} should be implemented such that variant actions applied on the site reflect the latest context diff --git a/packages/experiment-tag/src/util/cookie.ts b/packages/experiment-tag/src/util/cookie.ts deleted file mode 100644 index 2042b693..00000000 --- a/packages/experiment-tag/src/util/cookie.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { CampaignParser } from '@amplitude/analytics-core'; -import { CookieStorage } from '@amplitude/analytics-core'; -import { MKTG } from '@amplitude/analytics-core'; -import type { Campaign } from '@amplitude/analytics-core'; - -/** - * Utility function to generate and set marketing cookie - * Parses current campaign data from URL and referrer, then stores it in the marketing cookie - */ -export async function setMarketingCookie(apiKey: string) { - const storage = new CookieStorage({ - sameSite: 'Lax', - }); - - const parser = new CampaignParser(); - const storageKey = `AMP_${MKTG}_ORIGINAL_${apiKey.substring(0, 10)}`; - const campaign = await parser.parse(); - await storage.set(storageKey, campaign); -} diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 059882be..776675ae 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -3,6 +3,7 @@ import { safeGlobal } from '@amplitude/experiment-core'; import { ExperimentClient } from '@amplitude/experiment-js-client'; import { Base64 } from 'js-base64'; import { DefaultWebExperimentClient } from 'src/experiment'; +import { ConsentStatus } from 'src/types'; import * as antiFlickerUtils from 'src/util/anti-flicker'; import * as uuid from 'src/util/uuid'; import { stringify } from 'ts-jest'; @@ -1228,6 +1229,144 @@ describe('initializeExperiment', () => { expect(mockGlobal.sessionStorage.getItem(redirectStorageKey)).toBeNull(); }); + describe('consent status initialization and storage persistence', () => { + let mockConsentAwareStorage: any; + + beforeEach(() => { + // Mock ConsentAwareStorage + mockConsentAwareStorage = { + setItem: jest.fn(), + getItem: jest.fn(), + removeItem: jest.fn(), + setConsentStatus: jest.fn(), + }; + }); + + it('should initialize experiment with PENDING consent and store data in memory only', () => { + 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), + ); + + client.start(); + + expect(mockExposure).toHaveBeenCalledWith('test'); + + // With PENDING consent, data should be stored in memory only, not in actual localStorage + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + }); + + it('should initialize experiment with GRANTED consent and store data directly in actual storage', () => { + 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), + ); + + client.start(); + + expect(mockExposure).toHaveBeenCalledWith('test'); + expect(mockGlobal.localStorage.setItem).toHaveBeenCalledWith( + 'EXP_' + stringify(apiKey), + JSON.stringify({ web_exp_id: 'mock' }), + ); + }); + + it('should handle consent status change from PENDING to GRANTED during experiment lifecycle', () => { + 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), + ); + + client.start(); + + expect(mockExposure).toHaveBeenCalledWith('test'); + + // Clear any previous localStorage calls from start() + jest.clearAllMocks(); + + // Change consent status to GRANTED + client.setConsentStatus(ConsentStatus.GRANTED); + + // Trigger some action that would cause storage operations + client.applyVariants(); + + // Verify that previously stored data is now persisted to actual storage + expect(mockGlobal.localStorage.setItem).toHaveBeenCalled(); + }); + + it('should handle consent status change from PENDING to REJECTED during experiment lifecycle', () => { + // Start with PENDING consent + 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), + ); + client.start(); + + expect(mockExposure).toHaveBeenCalledWith('test'); + + // Clear any previous localStorage calls from start() + jest.clearAllMocks(); + + client.setConsentStatus(ConsentStatus.REJECTED); + + const experimentStorageKey = `EXP_${stringify(apiKey).slice(0, 10)}`; + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalledWith( + experimentStorageKey, + expect.stringContaining('web_exp_id'), + ); + + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + describe('remote evaluation - flag already stored in session storage', () => { const sessionStorageMock = () => { let store = {}; diff --git a/packages/experiment-tag/test/storage.test.ts b/packages/experiment-tag/test/storage.test.ts index f3703d9f..eadc8ed7 100644 --- a/packages/experiment-tag/test/storage.test.ts +++ b/packages/experiment-tag/test/storage.test.ts @@ -1,11 +1,25 @@ +import { CampaignParser, CookieStorage } from '@amplitude/analytics-core'; import * as coreUtil from '@amplitude/experiment-core'; import { ConsentAwareStorage } from '../src/storage/storage'; import { ConsentStatus } from '../src/types'; -// Mock the getGlobalScope function const spyGetGlobalScope = jest.spyOn(coreUtil, 'getGlobalScope'); +// Mock CampaignParser and CookieStorage +jest.mock('@amplitude/analytics-core', () => ({ + CampaignParser: jest.fn(), + CookieStorage: jest.fn(), + MKTG: 'MKTG', +})); + +const MockCampaignParser = CampaignParser as jest.MockedClass< + typeof CampaignParser +>; +const MockCookieStorage = CookieStorage as jest.MockedClass< + typeof CookieStorage +>; + describe('ConsentAwareStorage', () => { let mockGlobal: any; let storage: ConsentAwareStorage; @@ -110,18 +124,20 @@ describe('ConsentAwareStorage', () => { storage = new ConsentAwareStorage(ConsentStatus.REJECTED); }); - it('should not store data anywhere', () => { - storage.setItem('localStorage', 'testKey', { key: 'value' }); + it('should store data in memory but not in actual storage', () => { + const testData = { key: 'value' }; + storage.setItem('localStorage', 'testKey', testData); expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); - expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + expect(storage.getItem('localStorage', 'testKey')).toEqual(testData); }); - it('should not store data in sessionStorage', () => { - storage.setItem('sessionStorage', 'testKey', { key: 'value' }); + it('should store data in memory for sessionStorage but not in actual storage', () => { + const testData = { key: 'value' }; + storage.setItem('sessionStorage', 'testKey', testData); expect(mockGlobal.sessionStorage.setItem).not.toHaveBeenCalled(); - expect(storage.getItem('sessionStorage', 'testKey')).toBeNull(); + expect(storage.getItem('sessionStorage', 'testKey')).toEqual(testData); }); }); @@ -171,8 +187,17 @@ describe('ConsentAwareStorage', () => { storage = new ConsentAwareStorage(ConsentStatus.REJECTED); }); - it('should return null and not access actual storage', () => { + it('should return data from memory and not access actual storage', () => { + const testData = { key: 'value' }; + storage.setItem('localStorage', 'testKey', testData); + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); + }); + + it('should return null for non-existent keys', () => { + const retrieved = storage.getItem('localStorage', 'nonExistentKey'); expect(retrieved).toBeNull(); expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); }); @@ -283,4 +308,114 @@ describe('ConsentAwareStorage', () => { expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); }); }); + + describe('setMarketingCookie', () => { + let mockCampaignParser: any; + let mockCookieStorage: any; + const mockCampaign = { utm_source: 'test', utm_medium: 'test' }; + const testApiKey = 'test-api-key-1234567890'; + + beforeEach(() => { + mockCampaignParser = { + parse: jest.fn().mockResolvedValue(mockCampaign), + }; + mockCookieStorage = { + set: jest.fn().mockResolvedValue(undefined), + }; + + MockCampaignParser.mockImplementation(() => mockCampaignParser); + MockCookieStorage.mockImplementation(() => mockCookieStorage); + }); + + it('should set marketing cookie directly when consent is GRANTED', async () => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + + await storage.setMarketingCookie(testApiKey); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).toHaveBeenCalledWith({ sameSite: 'Lax' }); + expect(mockCookieStorage.set).toHaveBeenCalledWith( + 'AMP_MKTG_ORIGINAL_test-api-k', + mockCampaign, + ); + }); + + it('should store marketing cookie in memory when consent is PENDING', async () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + await storage.setMarketingCookie(testApiKey); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).not.toHaveBeenCalled(); + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + }); + + it('should persist marketing cookies when consent changes from PENDING to GRANTED', async () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + // Set marketing cookie while consent is pending + await storage.setMarketingCookie(testApiKey); + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + + // Change consent to granted + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Wait for async persistence to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(MockCookieStorage).toHaveBeenCalledWith({ sameSite: 'Lax' }); + expect(mockCookieStorage.set).toHaveBeenCalledWith( + 'AMP_MKTG_ORIGINAL_test-api-k', + mockCampaign, + ); + }); + + it('should handle marketing cookie errors gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const error = new Error('Cookie error'); + mockCookieStorage.set.mockRejectedValue(error); + + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + + await storage.setMarketingCookie(testApiKey); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to set marketing cookie:', + error, + ); + + consoleSpy.mockRestore(); + }); + + it('should handle campaign parser errors gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const error = new Error('Parser error'); + mockCampaignParser.parse.mockRejectedValue(error); + + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + + await storage.setMarketingCookie(testApiKey); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to set marketing cookie:', + error, + ); + + consoleSpy.mockRestore(); + }); + + it('should clear marketing cookies when consent changes from PENDING to REJECTED', async () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + await storage.setMarketingCookie(testApiKey); + + storage.setConsentStatus(ConsentStatus.REJECTED); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + }); + }); }); From 93a9de88741d4069816619e2ac15c99bc925b81f Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:19:26 -0700 Subject: [PATCH 05/20] clean up comments --- packages/experiment-tag/test/experiment.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 776675ae..5c2303b6 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1320,18 +1320,13 @@ describe('initializeExperiment', () => { // Clear any previous localStorage calls from start() jest.clearAllMocks(); - // Change consent status to GRANTED client.setConsentStatus(ConsentStatus.GRANTED); - // Trigger some action that would cause storage operations - client.applyVariants(); - // Verify that previously stored data is now persisted to actual storage expect(mockGlobal.localStorage.setItem).toHaveBeenCalled(); }); it('should handle consent status change from PENDING to REJECTED during experiment lifecycle', () => { - // Start with PENDING consent const mockGlobal = newMockGlobal({ experimentConfig: { consentOptions: { From f87086bd2d89fb488d07fadf646d53176fc03b9b Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:33:37 -0700 Subject: [PATCH 06/20] add marketing cookies test --- .../experiment-tag/test/experiment.test.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 5c2303b6..1cdc7263 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1,3 +1,4 @@ +import { CampaignParser, CookieStorage } from '@amplitude/analytics-core'; import * as experimentCore from '@amplitude/experiment-core'; import { safeGlobal } from '@amplitude/experiment-core'; import { ExperimentClient } from '@amplitude/experiment-js-client'; @@ -27,6 +28,20 @@ jest.mock('src/util/messenger', () => { }; }); +jest.mock('@amplitude/analytics-core', () => ({ + ...jest.requireActual('@amplitude/analytics-core'), + CampaignParser: jest.fn(), + CookieStorage: jest.fn(), + MKTG: 'MKTG', +})); + +const MockCampaignParser = CampaignParser as jest.MockedClass< + typeof CampaignParser +>; +const MockCookieStorage = CookieStorage as jest.MockedClass< + typeof CookieStorage +>; + const newMockGlobal = (overrides?: Record) => { const createStorageMock = () => { let store: Record = {}; @@ -1362,6 +1377,135 @@ describe('initializeExperiment', () => { }); }); + describe('marketing cookie with different consent status', () => { + let mockCampaignParser: any; + let mockCookieStorage: any; + const mockCampaign = { utm_source: 'test', utm_medium: 'test' }; + + beforeEach(() => { + mockCampaignParser = { + parse: jest.fn().mockResolvedValue(mockCampaign), + }; + mockCookieStorage = { + set: jest.fn().mockResolvedValue(undefined), + }; + + MockCampaignParser.mockImplementation(() => mockCampaignParser); + MockCookieStorage.mockImplementation(() => mockCookieStorage); + }); + + it('should set marketing cookie directly during redirect when consent is GRANTED', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.GRANTED, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2', + undefined, + DEFAULT_REDIRECT_SCOPE, + ), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2', + ); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).toHaveBeenCalledWith({ sameSite: 'Lax' }); + expect(mockCookieStorage.set).toHaveBeenCalledWith( + `AMP_MKTG_ORIGINAL_${stringify(apiKey).substring(0, 10)}`, + mockCampaign, + ); + }); + + it('should store marketing cookie in memory during redirect when consent is PENDING', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2', + undefined, + DEFAULT_REDIRECT_SCOPE, + ), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2', + ); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).not.toHaveBeenCalled(); + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + }); + + it('should not set marketing cookie during redirect when consent is REJECTED', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.REJECTED, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2', + undefined, + DEFAULT_REDIRECT_SCOPE, + ), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2', + ); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).not.toHaveBeenCalled(); + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + }); + }); + describe('remote evaluation - flag already stored in session storage', () => { const sessionStorageMock = () => { let store = {}; From 9c2428db4c9ac1b4cef95414c089139f139e9494 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:41:09 -0700 Subject: [PATCH 07/20] remove test --- packages/experiment-tag/test/storage.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/experiment-tag/test/storage.test.ts b/packages/experiment-tag/test/storage.test.ts index eadc8ed7..8a4f9aca 100644 --- a/packages/experiment-tag/test/storage.test.ts +++ b/packages/experiment-tag/test/storage.test.ts @@ -405,17 +405,5 @@ describe('ConsentAwareStorage', () => { consoleSpy.mockRestore(); }); - - it('should clear marketing cookies when consent changes from PENDING to REJECTED', async () => { - storage = new ConsentAwareStorage(ConsentStatus.PENDING); - - await storage.setMarketingCookie(testApiKey); - - storage.setConsentStatus(ConsentStatus.REJECTED); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(mockCookieStorage.set).not.toHaveBeenCalled(); - }); }); }); From cbc703eca8a3c92a72fa395bf3c6bc1d66b01fc8 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:50:20 -0700 Subject: [PATCH 08/20] simplify --- packages/experiment-tag/src/experiment.ts | 4 +--- packages/experiment-tag/src/storage/storage.ts | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index c54b6cce..8ce5947e 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -591,9 +591,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // set previous url - relevant for SPA if redirect happens before push/replaceState is complete this.previousUrl = this.globalScope.location.href; - this.storage.setMarketingCookie(this.apiKey).catch((error) => { - console.warn('Failed to set marketing cookie:', error); - }); + this.storage.setMarketingCookie(this.apiKey).then(); // perform redirection if (this.customRedirectHandler) { this.customRedirectHandler(targetUrl); diff --git a/packages/experiment-tag/src/storage/storage.ts b/packages/experiment-tag/src/storage/storage.ts index 6bf2afc6..5fed6a3f 100644 --- a/packages/experiment-tag/src/storage/storage.ts +++ b/packages/experiment-tag/src/storage/storage.ts @@ -102,7 +102,6 @@ export class ConsentAwareStorage { } this.inMemoryStorage.clear(); this.persistMarketingCookies().then(); - this.inMemoryMarketingCookies.clear(); } } From 720379c18705678a15af1e7a9fdb70b1095253d7 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 2 Oct 2025 13:04:29 -0700 Subject: [PATCH 09/20] refactor storage --- packages/experiment-tag/src/experiment.ts | 2 +- .../src/storage/consent-aware-storage.ts | 140 ++++++++++++++++++ .../experiment-tag/src/storage/storage.ts | 134 +---------------- packages/experiment-tag/src/util/messenger.ts | 2 +- packages/experiment-tag/test/storage.test.ts | 5 +- 5 files changed, 145 insertions(+), 138 deletions(-) create mode 100644 packages/experiment-tag/src/storage/consent-aware-storage.ts diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 8ce5947e..962031fc 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -17,7 +17,7 @@ import mutate, { MutationController } from 'dom-mutator'; import { MessageBus } from './message-bus'; import { showPreviewModeModal } from './preview/preview'; -import { ConsentAwareStorage } from './storage/storage'; +import { ConsentAwareStorage } from './storage/consent-aware-storage'; import { PageChangeEvent, SubscriptionManager } from './subscriptions'; import { ConsentOptions, diff --git a/packages/experiment-tag/src/storage/consent-aware-storage.ts b/packages/experiment-tag/src/storage/consent-aware-storage.ts new file mode 100644 index 00000000..a716be1a --- /dev/null +++ b/packages/experiment-tag/src/storage/consent-aware-storage.ts @@ -0,0 +1,140 @@ +import { CampaignParser, CookieStorage, MKTG } from '@amplitude/analytics-core'; +import type { Campaign } from '@amplitude/analytics-core'; + +import { ConsentStatus } from '../types'; + +import { + getStorage, + getStorageItem, + removeStorageItem, + setStorageItem, + StorageType, +} from './storage'; + +/** + * Consent-aware storage manager that handles persistence based on consent status + */ +export class ConsentAwareStorage { + private inMemoryStorage: Map> = new Map(); + private inMemoryMarketingCookies: Map = new Map(); + private consentStatus: ConsentStatus; + + constructor(initialConsentStatus: ConsentStatus) { + this.consentStatus = initialConsentStatus; + } + + /** + * Set the consent status and handle persistence accordingly + */ + public setConsentStatus(consentStatus: ConsentStatus): void { + this.consentStatus = consentStatus; + + if (consentStatus === ConsentStatus.GRANTED) { + for (const [storageType, storageMap] of this.inMemoryStorage.entries()) { + for (const [key, value] of storageMap.entries()) { + try { + const jsonString = JSON.stringify(value); + getStorage(storageType)?.setItem(key, jsonString); + } catch (error) { + console.warn(`Failed to persist data for key ${key}:`, error); + } + } + } + this.inMemoryStorage.clear(); + this.persistMarketingCookies().then(); + } + } + + /** + * Persist marketing cookies from memory to actual cookies + */ + private async persistMarketingCookies(): Promise { + for (const [ + storageKey, + campaign, + ] of this.inMemoryMarketingCookies.entries()) { + try { + const cookieStorage = new CookieStorage({ + sameSite: 'Lax', + }); + await cookieStorage.set(storageKey, campaign); + } catch (error) { + console.warn( + `Failed to persist marketing cookie for key ${storageKey}:`, + error, + ); + } + } + this.inMemoryMarketingCookies.clear(); + } + + /** + * Get a JSON value from storage with consent awareness + */ + public getItem(storageType: StorageType, key: string): T | null { + if (this.consentStatus === ConsentStatus.GRANTED) { + return getStorageItem(storageType, key); + } + + const storageMap = this.inMemoryStorage.get(storageType); + if (storageMap && storageMap.has(key)) { + return storageMap.get(key) as T; + } + + return null; + } + + /** + * Set a JSON value in storage with consent awareness + */ + public setItem(storageType: StorageType, key: string, value: unknown): void { + if (this.consentStatus === ConsentStatus.GRANTED) { + setStorageItem(storageType, key, value); + } else { + if (!this.inMemoryStorage.has(storageType)) { + this.inMemoryStorage.set(storageType, new Map()); + } + this.inMemoryStorage.get(storageType)?.set(key, value); + } + } + + /** + * Remove a value from storage with consent awareness + */ + public removeItem(storageType: StorageType, key: string): void { + const storageMap = this.inMemoryStorage.get(storageType); + if (this.consentStatus === ConsentStatus.GRANTED) { + removeStorageItem(storageType, key); + return; + } + if (storageMap) { + storageMap.delete(key); + if (storageMap.size === 0) { + this.inMemoryStorage.delete(storageType); + } + } + } + + /** + * Set marketing cookie with consent awareness + * Parses current campaign data from URL and referrer, then stores it in the marketing cookie + */ + public async setMarketingCookie(apiKey: string): Promise { + try { + const parser = new CampaignParser(); + const storageKey = `AMP_${MKTG}_ORIGINAL_${apiKey.substring(0, 10)}`; + const campaign = await parser.parse(); + + if (this.consentStatus === ConsentStatus.GRANTED) { + const cookieStorage = new CookieStorage({ + sameSite: 'Lax', + }); + await cookieStorage.set(storageKey, campaign); + } else { + this.inMemoryMarketingCookies.set(storageKey, campaign); + } + } catch (error) { + console.warn('Failed to set marketing cookie:', error); + } + } +} diff --git a/packages/experiment-tag/src/storage/storage.ts b/packages/experiment-tag/src/storage/storage.ts index 5fed6a3f..29f19644 100644 --- a/packages/experiment-tag/src/storage/storage.ts +++ b/packages/experiment-tag/src/storage/storage.ts @@ -1,9 +1,5 @@ -import { CampaignParser, CookieStorage, MKTG } from '@amplitude/analytics-core'; -import type { Campaign } from '@amplitude/analytics-core'; import { getGlobalScope } from '@amplitude/experiment-core'; -import { ConsentStatus } from '../types'; - export type StorageType = 'localStorage' | 'sessionStorage'; /** @@ -63,138 +59,10 @@ export const removeStorageItem = ( } }; -const getStorage = (storageType: StorageType): Storage | null => { +export const getStorage = (storageType: StorageType): Storage | null => { const globalScope = getGlobalScope(); if (!globalScope) { return null; } return globalScope[storageType]; }; - -/** - * Consent-aware storage manager that handles persistence based on consent status - */ -export class ConsentAwareStorage { - private inMemoryStorage: Map> = new Map(); - private inMemoryMarketingCookies: Map = new Map(); - private consentStatus: ConsentStatus = ConsentStatus.PENDING; - - constructor(initialConsentStatus: ConsentStatus) { - this.consentStatus = initialConsentStatus; - } - - /** - * Set the consent status and handle persistence accordingly - */ - public setConsentStatus(consentStatus: ConsentStatus): void { - this.consentStatus = consentStatus; - - if (consentStatus === ConsentStatus.GRANTED) { - for (const [storageType, storageMap] of this.inMemoryStorage.entries()) { - for (const [key, value] of storageMap.entries()) { - try { - const jsonString = JSON.stringify(value); - getStorage(storageType)?.setItem(key, jsonString); - } catch (error) { - console.warn(`Failed to persist data for key ${key}:`, error); - } - } - } - this.inMemoryStorage.clear(); - this.persistMarketingCookies().then(); - } - } - - /** - * Persist marketing cookies from memory to actual cookies - */ - private async persistMarketingCookies(): Promise { - for (const [ - storageKey, - campaign, - ] of this.inMemoryMarketingCookies.entries()) { - try { - const cookieStorage = new CookieStorage({ - sameSite: 'Lax', - }); - await cookieStorage.set(storageKey, campaign); - } catch (error) { - console.warn( - `Failed to persist marketing cookie for key ${storageKey}:`, - error, - ); - } - } - this.inMemoryMarketingCookies.clear(); - } - - /** - * Get a JSON value from storage with consent awareness - */ - public getItem(storageType: StorageType, key: string): T | null { - if (this.consentStatus === ConsentStatus.GRANTED) { - return getStorageItem(storageType, key); - } - - const storageMap = this.inMemoryStorage.get(storageType); - if (storageMap && storageMap.has(key)) { - return storageMap.get(key) as T; - } - - return null; - } - - /** - * Set a JSON value in storage with consent awareness - */ - public setItem(storageType: StorageType, key: string, value: unknown): void { - if (this.consentStatus === ConsentStatus.GRANTED) { - setStorageItem(storageType, key, value); - } else { - if (!this.inMemoryStorage.has(storageType)) { - this.inMemoryStorage.set(storageType, new Map()); - } - this.inMemoryStorage.get(storageType)?.set(key, value); - } - } - - /** - * Remove a value from storage with consent awareness - */ - public removeItem(storageType: StorageType, key: string): void { - const storageMap = this.inMemoryStorage.get(storageType); - if (this.consentStatus === ConsentStatus.GRANTED) { - removeStorageItem(storageType, key); - return; - } - if (storageMap) { - storageMap.delete(key); - if (storageMap.size === 0) { - this.inMemoryStorage.delete(storageType); - } - } - } - - /** - * Set marketing cookie with consent awareness - * Parses current campaign data from URL and referrer, then stores it in the marketing cookie - */ - public async setMarketingCookie(apiKey: string): Promise { - try { - const parser = new CampaignParser(); - const storageKey = `AMP_${MKTG}_ORIGINAL_${apiKey.substring(0, 10)}`; - const campaign = await parser.parse(); - - if (this.consentStatus === ConsentStatus.GRANTED) { - const cookieStorage = new CookieStorage({ - sameSite: 'Lax', - }); - await cookieStorage.set(storageKey, campaign); - } else { - this.inMemoryMarketingCookies.set(storageKey, campaign); - } - } catch (error) { - console.warn('Failed to set marketing cookie:', error); - } - } -} diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index 1cf0bb19..db9509ea 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -1,6 +1,6 @@ import { getGlobalScope } from '@amplitude/experiment-core'; -import { getStorageItem } from '../storage/storage'; +import { getStorageItem } from '../storage/consent-aware-storage'; interface VisualEditorSession { injectSrc: string; diff --git a/packages/experiment-tag/test/storage.test.ts b/packages/experiment-tag/test/storage.test.ts index 8a4f9aca..0e3eff17 100644 --- a/packages/experiment-tag/test/storage.test.ts +++ b/packages/experiment-tag/test/storage.test.ts @@ -1,8 +1,7 @@ import { CampaignParser, CookieStorage } from '@amplitude/analytics-core'; import * as coreUtil from '@amplitude/experiment-core'; - -import { ConsentAwareStorage } from '../src/storage/storage'; -import { ConsentStatus } from '../src/types'; +import { ConsentAwareStorage } from 'src/storage/consent-aware-storage'; +import { ConsentStatus } from 'src/types'; const spyGetGlobalScope = jest.spyOn(coreUtil, 'getGlobalScope'); From 9c8af0f3eb7445d97c780bb04dc0c213d26888ff Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 2 Oct 2025 13:15:00 -0700 Subject: [PATCH 10/20] fix import --- packages/experiment-tag/src/util/messenger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index db9509ea..1cf0bb19 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -1,6 +1,6 @@ import { getGlobalScope } from '@amplitude/experiment-core'; -import { getStorageItem } from '../storage/consent-aware-storage'; +import { getStorageItem } from '../storage/storage'; interface VisualEditorSession { injectSrc: string; From 58b395040f94389c2588d81befd851fee0957255 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:01:57 -0700 Subject: [PATCH 11/20] 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 fc3464ac..f58648d0 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()?.globalScope.experimentConfig.consentOptions.status === + getGlobalScope()?.experimentConfig.consentOptions.status === ConsentStatus.REJECTED ) { return; From 6067049aadefb863fc4d68a175af989056e535c3 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:02:13 -0700 Subject: [PATCH 12/20] Update packages/experiment-tag/src/storage/consent-aware-storage.ts Co-authored-by: Stephen Choi <106711294+stephen-choi-amplitude@users.noreply.github.com> --- packages/experiment-tag/src/storage/consent-aware-storage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/experiment-tag/src/storage/consent-aware-storage.ts b/packages/experiment-tag/src/storage/consent-aware-storage.ts index a716be1a..9ef38cdc 100644 --- a/packages/experiment-tag/src/storage/consent-aware-storage.ts +++ b/packages/experiment-tag/src/storage/consent-aware-storage.ts @@ -77,7 +77,7 @@ export class ConsentAwareStorage { } const storageMap = this.inMemoryStorage.get(storageType); - if (storageMap && storageMap.has(key)) { + if (storageMap?.has(key)) { return storageMap.get(key) as T; } From 7009ffb13ac43dc321c257ca09e0877eb7c039cd Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:20:28 -0700 Subject: [PATCH 13/20] update per comments --- .../src/storage/consent-aware-storage.ts | 2 +- .../experiment-tag/test/experiment.test.ts | 36 +++++++------------ 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/packages/experiment-tag/src/storage/consent-aware-storage.ts b/packages/experiment-tag/src/storage/consent-aware-storage.ts index a716be1a..5d6b6f2d 100644 --- a/packages/experiment-tag/src/storage/consent-aware-storage.ts +++ b/packages/experiment-tag/src/storage/consent-aware-storage.ts @@ -41,7 +41,7 @@ export class ConsentAwareStorage { } } this.inMemoryStorage.clear(); - this.persistMarketingCookies().then(); + this.persistMarketingCookies().catch(); } } diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 1cdc7263..1173b0df 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -65,7 +65,7 @@ const newMockGlobal = (overrides?: Record) => { const baseGlobal = { localStorage: createStorageMock(), sessionStorage: createStorageMock(), - document: { referrer: '' }, + document: { referrer: '', cookie: '' }, history: { replaceState: jest.fn() }, addEventListener: jest.fn(), experimentIntegration: { @@ -1245,18 +1245,6 @@ describe('initializeExperiment', () => { }); describe('consent status initialization and storage persistence', () => { - let mockConsentAwareStorage: any; - - beforeEach(() => { - // Mock ConsentAwareStorage - mockConsentAwareStorage = { - setItem: jest.fn(), - getItem: jest.fn(), - removeItem: jest.fn(), - setConsentStatus: jest.fn(), - }; - }); - it('should initialize experiment with PENDING consent and store data in memory only', () => { const mockGlobal = newMockGlobal({ experimentConfig: { @@ -1386,12 +1374,8 @@ describe('initializeExperiment', () => { mockCampaignParser = { parse: jest.fn().mockResolvedValue(mockCampaign), }; - mockCookieStorage = { - set: jest.fn().mockResolvedValue(undefined), - }; MockCampaignParser.mockImplementation(() => mockCampaignParser); - MockCookieStorage.mockImplementation(() => mockCookieStorage); }); it('should set marketing cookie directly during redirect when consent is GRANTED', async () => { @@ -1404,6 +1388,14 @@ describe('initializeExperiment', () => { }); mockGetGlobalScope.mockReturnValue(mockGlobal as any); + mockCookieStorage = { + set: jest.fn().mockImplementation((key, value) => { + mockGlobal.document.cookie = `${key}=${JSON.stringify(value)}`; + return Promise.resolve(); + }), + }; + MockCookieStorage.mockImplementation(() => mockCookieStorage); + const client = DefaultWebExperimentClient.getInstance( stringify(apiKey), JSON.stringify([ @@ -1423,13 +1415,9 @@ describe('initializeExperiment', () => { expect(mockGlobal.location.replace).toHaveBeenCalledWith( 'http://test.com/2', ); - - expect(MockCampaignParser).toHaveBeenCalledTimes(1); - expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); - expect(MockCookieStorage).toHaveBeenCalledWith({ sameSite: 'Lax' }); - expect(mockCookieStorage.set).toHaveBeenCalledWith( - `AMP_MKTG_ORIGINAL_${stringify(apiKey).substring(0, 10)}`, - mockCampaign, + expect(mockGlobal.document.cookie).toContain('AMP_MKTG_ORIGINAL_'); + expect(mockGlobal.document.cookie).toContain( + stringify(apiKey).substring(0, 10), ); }); From 3da987e22e45ddfaf99f0d08dbf90e0dc7068f00 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:59:41 -0700 Subject: [PATCH 14/20] feat: Cookie consent exposure handling (#222) * add consent-dependent exposure handler * fix import * move exposure handler * fix lint * add test cases * fix: attach timestamp to impression events (#223) * add timestamp tests * simplify comments * wrap integrationplugin * update tests * check track wrapping, finx lint * fix comment * fix init * nit: formatting * simplify * use date.now --- .../experiment-browser/src/types/exposure.ts | 4 + packages/experiment-tag/src/experiment.ts | 8 + .../consent-aware-exposure-handler.ts | 102 +++++ packages/experiment-tag/src/index.ts | 2 +- packages/experiment-tag/src/types.ts | 1 - .../consent-aware-exposure-handler.test.ts | 288 +++++++++++++ .../experiment-tag/test/experiment.test.ts | 387 ++++++++++++++++++ 7 files changed, 790 insertions(+), 2 deletions(-) create mode 100644 packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts create mode 100644 packages/experiment-tag/test/consent-aware-exposure-handler.test.ts 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 1173b0df..5bb16ccb 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1494,6 +1494,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 = {}; From c613caacb56c0bfd7c61a0583a3e24612cff2825 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Fri, 17 Oct 2025 12:02:18 -0700 Subject: [PATCH 15/20] fix: implement consent-aware default user provider and remote flag storage (#225) --- .../src/experimentClient.ts | 2 +- packages/experiment-browser/src/factory.ts | 7 +- .../src/providers/default.ts | 14 ++- packages/experiment-tag/src/experiment.ts | 26 ++++- .../src/storage/consent-aware-storage.ts | 97 ++++++++++++++++--- .../experiment-tag/src/storage/storage.ts | 57 ++++++----- packages/experiment-tag/src/util/messenger.ts | 7 +- packages/experiment-tag/src/util/url.ts | 18 ++-- .../experiment-tag/test/experiment.test.ts | 37 +++---- 9 files changed, 185 insertions(+), 80 deletions(-) diff --git a/packages/experiment-browser/src/experimentClient.ts b/packages/experiment-browser/src/experimentClient.ts index eb8157cd..ff079b4c 100644 --- a/packages/experiment-browser/src/experimentClient.ts +++ b/packages/experiment-browser/src/experimentClient.ts @@ -176,7 +176,7 @@ export class ExperimentClient implements Client { ? `${this.config.instanceName}-${internalInstanceName}` : this.config.instanceName; if (this.isWebExperiment) { - storage = new SessionStorage(); + storage = config?.['consentAwareStorage']?.['sessionStorage']; } else { storage = new LocalStorage(); } diff --git a/packages/experiment-browser/src/factory.ts b/packages/experiment-browser/src/factory.ts index 25f0aa6e..2cd61f4a 100644 --- a/packages/experiment-browser/src/factory.ts +++ b/packages/experiment-browser/src/factory.ts @@ -71,7 +71,12 @@ const newExperimentClient = ( ): ExperimentClient => { return new ExperimentClient(apiKey, { ...config, - userProvider: new DefaultUserProvider(config?.userProvider, apiKey), + userProvider: new DefaultUserProvider( + config?.userProvider, + apiKey, + config?.['consentAwareStorage']?.localStorage, + config?.['consentAwareStorage']?.sessionStorage, + ), }); }; diff --git a/packages/experiment-browser/src/providers/default.ts b/packages/experiment-browser/src/providers/default.ts index ca4ae5e1..cbce1d2b 100644 --- a/packages/experiment-browser/src/providers/default.ts +++ b/packages/experiment-browser/src/providers/default.ts @@ -4,6 +4,7 @@ import { UAParser } from '@amplitude/ua-parser-js'; import { LocalStorage } from '../storage/local-storage'; import { SessionStorage } from '../storage/session-storage'; import { ExperimentUserProvider } from '../types/provider'; +import { Storage } from '../types/storage'; import { ExperimentUser } from '../types/user'; export class DefaultUserProvider implements ExperimentUserProvider { @@ -13,17 +14,24 @@ export class DefaultUserProvider implements ExperimentUserProvider { ? this.globalScope?.navigator.userAgent : undefined; private readonly ua = new UAParser(this.userAgent).getResult(); - private readonly localStorage = new LocalStorage(); - private readonly sessionStorage = new SessionStorage(); + private readonly localStorage: Storage; + private readonly sessionStorage: Storage; private readonly storageKey: string; public readonly userProvider: ExperimentUserProvider | undefined; private readonly apiKey?: string; - constructor(userProvider?: ExperimentUserProvider, apiKey?: string) { + constructor( + userProvider?: ExperimentUserProvider, + apiKey?: string, + customLocalStorage?: Storage, + customSessionStorage?: Storage, + ) { this.userProvider = userProvider; this.apiKey = apiKey; this.storageKey = `EXP_${this.apiKey?.slice(0, 10)}_DEFAULT_USER_PROVIDER`; + this.localStorage = customLocalStorage || new LocalStorage(); + this.sessionStorage = customSessionStorage || new SessionStorage(); } getUser(): ExperimentUser { diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index eb6dd79f..de212aba 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -18,7 +18,15 @@ 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'; +import { + ConsentAwareLocalStorage, + ConsentAwareSessionStorage, + ConsentAwareStorage, +} from './storage/consent-aware-storage'; +import { + getAndParseStorageItem, + setAndStringifyStorageItem, +} from './storage/storage'; import { PageChangeEvent, SubscriptionManager } from './subscriptions'; import { ConsentOptions, @@ -161,6 +169,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore internalInstanceNameSuffix: 'web', + consentAwareStorage: { + localStorage: new ConsentAwareLocalStorage(this.storage), + sessionStorage: new ConsentAwareSessionStorage(this.storage), + }, initialFlags: initialFlagsString, // timeout for fetching remote flags fetchTimeoutMillis: 1000, @@ -878,9 +890,13 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } }); - this.storage.setItem('sessionStorage', PREVIEW_MODE_SESSION_KEY, { - previewFlags: this.previewFlags, - }); + setAndStringifyStorageItem( + 'sessionStorage', + PREVIEW_MODE_SESSION_KEY, + { + previewFlags: this.previewFlags, + }, + ); const previewParamsToRemove = [ ...Object.keys(this.previewFlags), PREVIEW_MODE_PARAM, @@ -896,7 +912,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // if in preview mode, listen for ForceVariant messages WindowMessenger.setup(); } else { - const previewState: PreviewState | null = this.storage.getItem( + const previewState = getAndParseStorageItem( 'sessionStorage', PREVIEW_MODE_SESSION_KEY, ); diff --git a/packages/experiment-tag/src/storage/consent-aware-storage.ts b/packages/experiment-tag/src/storage/consent-aware-storage.ts index 76311e9d..014daf43 100644 --- a/packages/experiment-tag/src/storage/consent-aware-storage.ts +++ b/packages/experiment-tag/src/storage/consent-aware-storage.ts @@ -4,10 +4,11 @@ import type { Campaign } from '@amplitude/analytics-core'; import { ConsentStatus } from '../types'; import { - getStorage, - getStorageItem, + getAndParseStorageItem, + getRawStorageItem, removeStorageItem, - setStorageItem, + setAndStringifyStorageItem, + setRawStorageItem, StorageType, } from './storage'; @@ -16,6 +17,7 @@ import { */ export class ConsentAwareStorage { private inMemoryStorage: Map> = new Map(); + private inMemoryRawStorage: Map> = new Map(); private inMemoryMarketingCookies: Map = new Map(); private consentStatus: ConsentStatus; @@ -32,15 +34,19 @@ export class ConsentAwareStorage { if (consentStatus === ConsentStatus.GRANTED) { for (const [storageType, storageMap] of this.inMemoryStorage.entries()) { for (const [key, value] of storageMap.entries()) { - try { - const jsonString = JSON.stringify(value); - getStorage(storageType)?.setItem(key, jsonString); - } catch (error) { - console.warn(`Failed to persist data for key ${key}:`, error); - } + setAndStringifyStorageItem(storageType, key, value); + } + } + for (const [ + storageType, + storageMap, + ] of this.inMemoryRawStorage.entries()) { + for (const [key, value] of storageMap.entries()) { + setRawStorageItem(storageType, key, value); } } this.inMemoryStorage.clear(); + this.inMemoryRawStorage.clear(); this.persistMarketingCookies().catch(); } } @@ -73,7 +79,8 @@ export class ConsentAwareStorage { */ public getItem(storageType: StorageType, key: string): T | null { if (this.consentStatus === ConsentStatus.GRANTED) { - return getStorageItem(storageType, key); + const value = getAndParseStorageItem(storageType, key); + return value as T; } const storageMap = this.inMemoryStorage.get(storageType); @@ -89,7 +96,7 @@ export class ConsentAwareStorage { */ public setItem(storageType: StorageType, key: string, value: unknown): void { if (this.consentStatus === ConsentStatus.GRANTED) { - setStorageItem(storageType, key, value); + setAndStringifyStorageItem(storageType, key, value); } else { if (!this.inMemoryStorage.has(storageType)) { this.inMemoryStorage.set(storageType, new Map()); @@ -115,6 +122,42 @@ export class ConsentAwareStorage { } } + /** + * Get a raw string value from storage with consent awareness + * This is used by Storage interface implementations that expect raw strings + */ + public getRawItem(storageType: StorageType, key: string): string { + if (this.consentStatus === ConsentStatus.GRANTED) { + return getRawStorageItem(storageType, key); + } + + const storageMap = this.inMemoryRawStorage.get(storageType); + if (storageMap && storageMap.has(key)) { + return storageMap.get(key) || ''; + } + + return ''; + } + + /** + * Set a raw string value in storage with consent awareness + * This is used by Storage interface implementations that work with raw strings + */ + public setRawItem( + storageType: StorageType, + key: string, + value: string, + ): void { + if (this.consentStatus === ConsentStatus.GRANTED) { + setRawStorageItem(storageType, key, value); + } else { + if (!this.inMemoryRawStorage.has(storageType)) { + this.inMemoryRawStorage.set(storageType, new Map()); + } + this.inMemoryRawStorage.get(storageType)?.set(key, value); + } + } + /** * Set marketing cookie with consent awareness * Parses current campaign data from URL and referrer, then stores it in the marketing cookie @@ -138,3 +181,35 @@ export class ConsentAwareStorage { } } } + +export class ConsentAwareLocalStorage { + constructor(private consentAwareStorage: ConsentAwareStorage) {} + + get(key: string): string { + return this.consentAwareStorage.getRawItem('localStorage', key); + } + + put(key: string, value: string): void { + this.consentAwareStorage.setRawItem('localStorage', key, value); + } + + delete(key: string): void { + this.consentAwareStorage.removeItem('localStorage', key); + } +} + +export class ConsentAwareSessionStorage { + constructor(private consentAwareStorage: ConsentAwareStorage) {} + + get(key: string): string { + return this.consentAwareStorage.getRawItem('sessionStorage', key); + } + + put(key: string, value: string): void { + this.consentAwareStorage.setRawItem('sessionStorage', key, value); + } + + delete(key: string): void { + this.consentAwareStorage.removeItem('sessionStorage', key); + } +} diff --git a/packages/experiment-tag/src/storage/storage.ts b/packages/experiment-tag/src/storage/storage.ts index 29f19644..e18948a7 100644 --- a/packages/experiment-tag/src/storage/storage.ts +++ b/packages/experiment-tag/src/storage/storage.ts @@ -3,43 +3,54 @@ import { getGlobalScope } from '@amplitude/experiment-core'; export type StorageType = 'localStorage' | 'sessionStorage'; /** - * Get a JSON value from storage and parse it + * Get a JSON string value from storage * @param storageType - The type of storage to use ('localStorage' or 'sessionStorage') - * @param key - The key to retrieve - * @returns The parsed JSON value or null if not found or invalid JSON + * @param key - The key to retrieve the value for + * @returns The JSON string value, or null if not found */ -export const getStorageItem = ( +export const getRawStorageItem = ( + storageType: StorageType, + key: string, +): string => { + return getStorage(storageType)?.getItem(key) || ''; +}; + +/** + * Set a JSON string value in storage + * @param storageType - The type of storage to use ('localStorage' or 'sessionStorage') + * @param key - The key to set the value for + * @param value - The JSON string value to set + */ +export const setRawStorageItem = ( + storageType: StorageType, + key: string, + value: string, +): void => { + getStorage(storageType)?.setItem(key, value); +}; + +export const getAndParseStorageItem = ( storageType: StorageType, key: string, ): T | null => { + const value = getRawStorageItem(storageType, key); try { - const value = getStorage(storageType)?.getItem(key); - if (!value) { - return null; - } - return JSON.parse(value) as T; - } catch (error) { - console.warn(`Failed to get and parse JSON from ${storageType}:`, error); + return JSON.parse(value); + } catch { return null; } }; -/** - * Set a JSON value in storage by stringifying it - * @param storageType - The type of storage to use ('localStorage' or 'sessionStorage') - * @param key - The key to store the value under - * @param value - The value to stringify and store - */ -export const setStorageItem = ( +export const setAndStringifyStorageItem = ( storageType: StorageType, key: string, - value: unknown, + value: T, ): void => { try { - const jsonString = JSON.stringify(value); - getStorage(storageType)?.setItem(key, jsonString); + const stringValue = JSON.stringify(value); + setRawStorageItem(storageType, key, stringValue); } catch (error) { - console.warn(`Failed to stringify and set JSON in ${storageType}:`, error); + console.warn(`Failed to persist data for key ${key}:`, error); } }; @@ -59,7 +70,7 @@ export const removeStorageItem = ( } }; -export const getStorage = (storageType: StorageType): Storage | null => { +const getStorage = (storageType: StorageType): Storage | null => { const globalScope = getGlobalScope(); if (!globalScope) { return null; diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index 1cf0bb19..8e754a51 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -1,6 +1,6 @@ import { getGlobalScope } from '@amplitude/experiment-core'; -import { getStorageItem } from '../storage/storage'; +import { getAndParseStorageItem } from '../storage/storage'; interface VisualEditorSession { injectSrc: string; @@ -73,10 +73,11 @@ export class WindowMessenger { * Retrieve stored session data (read-only) */ private static getStoredSession(): VisualEditorSession | null { - const sessionData = getStorageItem( + const sessionData = getAndParseStorageItem( 'sessionStorage', VISUAL_EDITOR_SESSION_KEY, - ); + ) as VisualEditorSession; + if (!sessionData) { return null; } diff --git a/packages/experiment-tag/src/util/url.ts b/packages/experiment-tag/src/util/url.ts index 12089f27..5d5bd66b 100644 --- a/packages/experiment-tag/src/util/url.ts +++ b/packages/experiment-tag/src/util/url.ts @@ -1,7 +1,7 @@ import { getGlobalScope } from '@amplitude/experiment-core'; import { PREVIEW_MODE_PARAM, PREVIEW_MODE_SESSION_KEY } from '../experiment'; -import { getStorageItem } from '../storage/storage'; +import { getAndParseStorageItem } from '../storage/storage'; import { PreviewState } from '../types'; export const getUrlParams = (): Record => { @@ -88,15 +88,15 @@ export const isPreviewMode = (): boolean => { if (getUrlParams()[PREVIEW_MODE_PARAM] === 'true') { return true; } - const previewState = getStorageItem( + const previewState = getAndParseStorageItem( 'sessionStorage', PREVIEW_MODE_SESSION_KEY, - ) as PreviewState; - if ( - previewState?.previewFlags && - Object.keys(previewState.previewFlags).length > 0 - ) { - return true; + ); + if (!previewState) { + return false; } - return false; + return ( + previewState.previewFlags && + Object.keys(previewState.previewFlags).length > 0 + ); }; diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 5bb16ccb..f77bf1e3 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1882,28 +1882,17 @@ describe('initializeExperiment', () => { }); describe('remote evaluation - flag already stored in session storage', () => { - const sessionStorageMock = () => { - let store = {}; - return { - getItem: jest.fn((key) => store[key] || null), - setItem: jest.fn((key, value) => { - store[key] = value; - }), - removeItem: jest.fn((key) => { - delete store[key]; - }), - clear: jest.fn(() => { - store = {}; - }), - }; - }; + let testMockGlobal: any; + beforeEach(() => { - Object.defineProperty(safeGlobal, 'sessionStorage', { - value: sessionStorageMock(), + testMockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.GRANTED, + }, + }, }); - }); - afterEach(() => { - safeGlobal.sessionStorage.clear(); + mockGetGlobalScope.mockReturnValue(testMockGlobal); }); test('evaluated, applied, and impression tracked, start updates flag in storage, applied, impression deduped', async () => { const apiKey = 'api1'; @@ -1920,7 +1909,7 @@ describe('initializeExperiment', () => { flagVersion: 2, }, ); - safeGlobal.sessionStorage.setItem( + testMockGlobal.sessionStorage.setItem( storageKey, JSON.stringify({ test: storedFlag }), ); @@ -1967,7 +1956,7 @@ describe('initializeExperiment', () => { expect(mockExposure).toHaveBeenCalledTimes(1); // Check remote flag store in storage const flags = JSON.parse( - safeGlobal.sessionStorage.getItem(storageKey) as string, + testMockGlobal.sessionStorage.getItem(storageKey) as string, ); expect(flags['test'].metadata.flagVersion).toEqual(4); expect(flags['test'].metadata.evaluationMode).toEqual('local'); @@ -1994,7 +1983,7 @@ describe('initializeExperiment', () => { flagVersion: 2, }, ); - safeGlobal.sessionStorage.setItem( + testMockGlobal.sessionStorage.setItem( storageKey, JSON.stringify({ test: storedFlag }), ); @@ -2049,7 +2038,7 @@ describe('initializeExperiment', () => { expect(mockExposure).toHaveBeenCalledTimes(2); // Check remote flag store in storage const flags = JSON.parse( - safeGlobal.sessionStorage.getItem(storageKey) as string, + testMockGlobal.sessionStorage.getItem(storageKey) as string, ); expect(flags['test'].metadata.flagVersion).toEqual(4); expect(flags['test'].metadata.evaluationMode).toEqual('local'); From 82ca0dab419c3391fcfdaae82f0276fbf714ea94 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Fri, 17 Oct 2025 14:10:05 -0700 Subject: [PATCH 16/20] fix: delete all persisted data upon consent rejection (#227) --- packages/experiment-tag/src/experiment.ts | 25 +++++++++++------ packages/experiment-tag/src/index.ts | 2 ++ packages/experiment-tag/src/storage/keys.ts | 27 +++++++++++++++++++ .../experiment-tag/src/storage/storage.ts | 22 +++++++++++++++ packages/experiment-tag/src/util/messenger.ts | 8 +++--- packages/experiment-tag/src/util/url.ts | 5 ++-- 6 files changed, 74 insertions(+), 15 deletions(-) create mode 100644 packages/experiment-tag/src/storage/keys.ts diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index de212aba..1d519863 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -24,6 +24,13 @@ import { ConsentAwareStorage, } from './storage/consent-aware-storage'; import { + getExperimentStorageKey, + getPreviewModeSessionKey, + getRedirectStorageKey, + getVisualEditorSessionKey, +} from './storage/keys'; +import { + deletePersistedData, getAndParseStorageItem, setAndStringifyStorageItem, } from './storage/storage'; @@ -46,7 +53,7 @@ import { } from './types'; import { applyAntiFlickerCss } from './util/anti-flicker'; import { getInjectUtils } from './util/inject-utils'; -import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger'; +import { WindowMessenger } from './util/messenger'; import { patchRemoveChild } from './util/patch'; import { getUrlParams, @@ -62,7 +69,6 @@ const MUTATE_ACTION = 'mutate'; export const INJECT_ACTION = 'inject'; const REDIRECT_ACTION = 'redirect'; export const PREVIEW_MODE_PARAM = 'PREVIEW'; -export const PREVIEW_MODE_SESSION_KEY = 'amp-preview-mode'; const VISUAL_EDITOR_PARAM = 'VISUAL_EDITOR'; safeGlobal.Experiment = FeatureExperiment; @@ -200,7 +206,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { const urlParams = getUrlParams(); this.isVisualEditorMode = urlParams[VISUAL_EDITOR_PARAM] === 'true' || - this.storage.getItem('sessionStorage', VISUAL_EDITOR_SESSION_KEY) !== + this.storage.getItem('sessionStorage', getVisualEditorSessionKey()) !== null; this.subscriptionManager = new SubscriptionManager( this, @@ -234,7 +240,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // fire url_change upon landing on page, set updateActivePagesOnly to not trigger variant actions this.messageBus.publish('url_change', { updateActivePages: true }); - const experimentStorageName = `EXP_${this.apiKey.slice(0, 10)}`; + const experimentStorageName = getExperimentStorageKey(this.apiKey); const user = this.storage.getItem( 'localStorage', @@ -554,6 +560,9 @@ export class DefaultWebExperimentClient implements WebExperimentClient { public setConsentStatus(consentStatus: ConsentStatus) { this.consentOptions.status = consentStatus; this.storage.setConsentStatus(consentStatus); + if (consentStatus === ConsentStatus.REJECTED) { + deletePersistedData(this.apiKey, this.config); + } this.consentAwareExposureHandler.setConsentStatus(consentStatus); } @@ -829,7 +838,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { variant: Variant, redirectUrl: string, ) { - const redirectStorageKey = `EXP_${this.apiKey.slice(0, 10)}_REDIRECT`; + const redirectStorageKey = getRedirectStorageKey(this.apiKey); // Store the current flag and variant for exposure tracking after redirect const storedRedirects = this.storage.getItem('sessionStorage', redirectStorageKey) || {}; @@ -839,7 +848,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { private fireStoredRedirectImpressions() { // Check for stored redirects and process them - const redirectStorageKey = `EXP_${this.apiKey.slice(0, 10)}_REDIRECT`; + const redirectStorageKey = getRedirectStorageKey(this.apiKey); const storedRedirects = this.storage.getItem('sessionStorage', redirectStorageKey) || {}; @@ -892,7 +901,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { setAndStringifyStorageItem( 'sessionStorage', - PREVIEW_MODE_SESSION_KEY, + getPreviewModeSessionKey(), { previewFlags: this.previewFlags, }, @@ -914,7 +923,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } else { const previewState = getAndParseStorageItem( 'sessionStorage', - PREVIEW_MODE_SESSION_KEY, + getPreviewModeSessionKey(), ); if (previewState) { this.previewFlags = previewState.previewFlags; diff --git a/packages/experiment-tag/src/index.ts b/packages/experiment-tag/src/index.ts index 12d49ea0..d9ff1c81 100644 --- a/packages/experiment-tag/src/index.ts +++ b/packages/experiment-tag/src/index.ts @@ -3,6 +3,7 @@ import { getGlobalScope } from '@amplitude/experiment-core'; import { DefaultWebExperimentClient } from './experiment'; import { HttpClient } from './preview/http'; import { SdkPreviewApi } from './preview/preview-api'; +import { deletePersistedData } from './storage/storage'; import { ConsentStatus, WebExperimentConfig } from './types'; import { applyAntiFlickerCss } from './util/anti-flicker'; import { isPreviewMode } from './util/url'; @@ -17,6 +18,7 @@ export const initialize = ( getGlobalScope()?.experimentConfig?.consentOptions?.status === ConsentStatus.REJECTED ) { + deletePersistedData(apiKey, config); return; } const shouldFetchConfigs = diff --git a/packages/experiment-tag/src/storage/keys.ts b/packages/experiment-tag/src/storage/keys.ts new file mode 100644 index 00000000..a5971aad --- /dev/null +++ b/packages/experiment-tag/src/storage/keys.ts @@ -0,0 +1,27 @@ +import { WebExperimentConfig } from '../types'; + +export const getExperimentStorageKey = (apiKey: string): string => { + return `EXP_${apiKey.slice(0, 10)}`; +}; + +export const getDefaultUserProviderStorageKey = (apiKey: string): string => { + return `EXP_${apiKey.slice(0, 10)}_DEFAULT_USER_PROVIDER`; +}; + +export const getUnsentEventsStorageKey = ( + config: WebExperimentConfig, +): string => { + return `EXP_unsent_${config.instanceName ?? 'default_instance'}`; +}; + +export const getRedirectStorageKey = (apiKey: string): string => { + return `EXP_${apiKey.slice(0, 10)}_REDIRECT`; +}; + +export const getPreviewModeSessionKey = (): string => { + return 'amp-preview-mode'; +}; + +export const getVisualEditorSessionKey = (): string => { + return 'visual-editor-state'; +}; diff --git a/packages/experiment-tag/src/storage/storage.ts b/packages/experiment-tag/src/storage/storage.ts index e18948a7..ce5b13d1 100644 --- a/packages/experiment-tag/src/storage/storage.ts +++ b/packages/experiment-tag/src/storage/storage.ts @@ -1,5 +1,13 @@ import { getGlobalScope } from '@amplitude/experiment-core'; +import { WebExperimentConfig } from '../types'; + +import { + getDefaultUserProviderStorageKey, + getExperimentStorageKey, + getUnsentEventsStorageKey, +} from './keys'; + export type StorageType = 'localStorage' | 'sessionStorage'; /** @@ -77,3 +85,17 @@ const getStorage = (storageType: StorageType): Storage | null => { } return globalScope[storageType]; }; + +export const deletePersistedData = ( + apiKey: string, + config: WebExperimentConfig, +): void => { + const experimentStorageKey = getExperimentStorageKey(apiKey); + const defaultUserProviderStorageKey = + getDefaultUserProviderStorageKey(apiKey); + const unsentEventsStorageKey = getUnsentEventsStorageKey(config); + removeStorageItem('localStorage', experimentStorageKey); + removeStorageItem('localStorage', defaultUserProviderStorageKey); + removeStorageItem('sessionStorage', defaultUserProviderStorageKey); + removeStorageItem('localStorage', unsentEventsStorageKey); +}; diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index 8e754a51..ad965659 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -1,5 +1,6 @@ import { getGlobalScope } from '@amplitude/experiment-core'; +import { getVisualEditorSessionKey } from '../storage/keys'; import { getAndParseStorageItem } from '../storage/storage'; interface VisualEditorSession { @@ -7,8 +8,6 @@ interface VisualEditorSession { amplitudeWindowUrl: string; } -export const VISUAL_EDITOR_SESSION_KEY = 'visual-editor-state'; - export class WindowMessenger { static setup() { let state: 'closed' | 'opening' | 'open' = 'closed'; @@ -75,9 +74,8 @@ export class WindowMessenger { private static getStoredSession(): VisualEditorSession | null { const sessionData = getAndParseStorageItem( 'sessionStorage', - VISUAL_EDITOR_SESSION_KEY, - ) as VisualEditorSession; - + getVisualEditorSessionKey(), + ); if (!sessionData) { return null; } diff --git a/packages/experiment-tag/src/util/url.ts b/packages/experiment-tag/src/util/url.ts index 5d5bd66b..1c1b3656 100644 --- a/packages/experiment-tag/src/util/url.ts +++ b/packages/experiment-tag/src/util/url.ts @@ -1,6 +1,7 @@ import { getGlobalScope } from '@amplitude/experiment-core'; -import { PREVIEW_MODE_PARAM, PREVIEW_MODE_SESSION_KEY } from '../experiment'; +import { PREVIEW_MODE_PARAM } from '../experiment'; +import { getPreviewModeSessionKey } from '../storage/keys'; import { getAndParseStorageItem } from '../storage/storage'; import { PreviewState } from '../types'; @@ -90,7 +91,7 @@ export const isPreviewMode = (): boolean => { } const previewState = getAndParseStorageItem( 'sessionStorage', - PREVIEW_MODE_SESSION_KEY, + getPreviewModeSessionKey(), ); if (!previewState) { return false; From c5339180c4593db38d0a19e5877ab1582c20d43f Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Fri, 17 Oct 2025 14:59:53 -0700 Subject: [PATCH 17/20] handle more set consent states --- packages/experiment-tag/src/experiment.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 1d519863..3a7ec632 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -558,6 +558,12 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } public setConsentStatus(consentStatus: ConsentStatus) { + if ( + consentStatus == undefined || + consentStatus === this.consentOptions.status + ) { + return; + } this.consentOptions.status = consentStatus; this.storage.setConsentStatus(consentStatus); if (consentStatus === ConsentStatus.REJECTED) { From b5d3b5b20bc99b191cdcc4da8389960a4dcf7185 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 22 Oct 2025 13:25:09 -0700 Subject: [PATCH 18/20] campaign should use consent-aware storage --- packages/experiment-tag/src/experiment.ts | 6 +- packages/experiment-tag/src/storage/keys.ts | 6 + .../experiment-tag/src/storage/storage.ts | 4 + packages/experiment-tag/src/util/campaign.ts | 13 +- .../experiment-tag/test/experiment.test.ts | 80 +++++--- .../experiment-tag/test/util/campaign.test.ts | 182 +++++++++++------- 6 files changed, 187 insertions(+), 104 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 45eac59c..e1de9f0a 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -280,7 +280,11 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } } - const enrichedUser = await enrichUserWithCampaignData(this.apiKey, user); + const enrichedUser = await enrichUserWithCampaignData( + this.apiKey, + user, + this.storage, + ); // If no integration has been set, use an Amplitude integration. if (!this.globalScope.experimentIntegration) { diff --git a/packages/experiment-tag/src/storage/keys.ts b/packages/experiment-tag/src/storage/keys.ts index a5971aad..e659543d 100644 --- a/packages/experiment-tag/src/storage/keys.ts +++ b/packages/experiment-tag/src/storage/keys.ts @@ -1,3 +1,5 @@ +import { MKTG } from '@amplitude/analytics-core'; + import { WebExperimentConfig } from '../types'; export const getExperimentStorageKey = (apiKey: string): string => { @@ -25,3 +27,7 @@ export const getPreviewModeSessionKey = (): string => { export const getVisualEditorSessionKey = (): string => { return 'visual-editor-state'; }; + +export const getPersistedURLParamsKey = (apiKey: string): string => { + return `EXP_${MKTG}_${apiKey.substring(0, 10)}`; +}; diff --git a/packages/experiment-tag/src/storage/storage.ts b/packages/experiment-tag/src/storage/storage.ts index ce5b13d1..9a9571a2 100644 --- a/packages/experiment-tag/src/storage/storage.ts +++ b/packages/experiment-tag/src/storage/storage.ts @@ -5,6 +5,7 @@ import { WebExperimentConfig } from '../types'; import { getDefaultUserProviderStorageKey, getExperimentStorageKey, + getPersistedURLParamsKey, getUnsentEventsStorageKey, } from './keys'; @@ -94,8 +95,11 @@ export const deletePersistedData = ( const defaultUserProviderStorageKey = getDefaultUserProviderStorageKey(apiKey); const unsentEventsStorageKey = getUnsentEventsStorageKey(config); + const persistedURLParamsKey = getPersistedURLParamsKey(apiKey); + removeStorageItem('localStorage', experimentStorageKey); removeStorageItem('localStorage', defaultUserProviderStorageKey); removeStorageItem('sessionStorage', defaultUserProviderStorageKey); removeStorageItem('localStorage', unsentEventsStorageKey); + removeStorageItem('sessionStorage', persistedURLParamsKey); }; diff --git a/packages/experiment-tag/src/util/campaign.ts b/packages/experiment-tag/src/util/campaign.ts index 3ebe5754..c092e2a1 100644 --- a/packages/experiment-tag/src/util/campaign.ts +++ b/packages/experiment-tag/src/util/campaign.ts @@ -8,7 +8,8 @@ import { import { UTMParameters } from '@amplitude/analytics-core/lib/esm/types/campaign'; import { type ExperimentUser } from '@amplitude/experiment-js-client'; -import { getStorageItem, setStorageItem } from './storage'; +import { ConsentAwareStorage } from '../storage/consent-aware-storage'; +import { getPersistedURLParamsKey } from '../storage/keys'; /** * Enriches the user object's userProperties with UTM parameters based on priority: @@ -19,12 +20,13 @@ import { getStorageItem, setStorageItem } from './storage'; export async function enrichUserWithCampaignData( apiKey: string, user: ExperimentUser, + storage: ConsentAwareStorage, ): Promise { const experimentStorageKey = `EXP_${MKTG}_${apiKey.substring(0, 10)}`; const [currentCampaign, persistedAmplitudeCampaign] = await fetchCampaignData( apiKey, ); - const persistedExperimentCampaign = getStorageItem( + const persistedExperimentCampaign = storage.getItem( 'localStorage', experimentStorageKey, ); @@ -48,7 +50,7 @@ export async function enrichUserWithCampaignData( } if (Object.keys(utmParams).length > 0) { - persistUrlParams(apiKey, utmParams); + persistUrlParams(apiKey, utmParams, storage); return { ...user, persisted_url_param: utmParams, @@ -63,9 +65,10 @@ export async function enrichUserWithCampaignData( export function persistUrlParams( apiKey: string, campaign: Record, + storage: ConsentAwareStorage, ): void { - const experimentStorageKey = `EXP_${MKTG}_${apiKey.substring(0, 10)}`; - setStorageItem('localStorage', experimentStorageKey, campaign); + const experimentStorageKey = getPersistedURLParamsKey(apiKey); + storage.setItem('localStorage', experimentStorageKey, campaign); } async function fetchCampaignData( diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 027be5ab..c8a46551 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -156,6 +156,17 @@ describe('initializeExperiment', () => { antiFlickerSpy = jest .spyOn(antiFlickerUtils, 'applyAntiFlickerCss') .mockImplementation(jest.fn()); + + const mockCampaignParser = { + parse: jest.fn().mockResolvedValue({}), + }; + MockCampaignParser.mockImplementation(() => mockCampaignParser as any); + + const mockCookieStorage = { + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn(), + }; + MockCookieStorage.mockImplementation(() => mockCookieStorage as any); }); test('should initialize experiment with empty user', async () => { @@ -1238,7 +1249,7 @@ describe('initializeExperiment', () => { }); describe('consent status initialization and storage persistence', () => { - it('should initialize experiment with PENDING consent and store data in memory only', () => { + it('should initialize experiment with PENDING consent and store data in memory only', async () => { const mockGlobal = newMockGlobal({ experimentConfig: { consentOptions: { @@ -1256,7 +1267,7 @@ describe('initializeExperiment', () => { JSON.stringify(DEFAULT_PAGE_OBJECTS), ); - client.start(); + await client.start(); expect(mockExposure).toHaveBeenCalledWith('test'); @@ -1264,7 +1275,7 @@ describe('initializeExperiment', () => { expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); }); - it('should initialize experiment with GRANTED consent and store data directly in actual storage', () => { + it('should initialize experiment with GRANTED consent and store data directly in actual storage', async () => { const mockGlobal = newMockGlobal({ experimentConfig: { consentOptions: { @@ -1282,7 +1293,7 @@ describe('initializeExperiment', () => { JSON.stringify(DEFAULT_PAGE_OBJECTS), ); - client.start(); + await client.start(); expect(mockExposure).toHaveBeenCalledWith('test'); expect(mockGlobal.localStorage.setItem).toHaveBeenCalledWith( @@ -1291,7 +1302,7 @@ describe('initializeExperiment', () => { ); }); - it('should handle consent status change from PENDING to GRANTED during experiment lifecycle', () => { + it('should handle consent status change from PENDING to GRANTED during experiment lifecycle', async () => { const mockGlobal = newMockGlobal({ experimentConfig: { consentOptions: { @@ -1309,7 +1320,7 @@ describe('initializeExperiment', () => { JSON.stringify(DEFAULT_PAGE_OBJECTS), ); - client.start(); + await client.start(); expect(mockExposure).toHaveBeenCalledWith('test'); @@ -1322,7 +1333,7 @@ describe('initializeExperiment', () => { expect(mockGlobal.localStorage.setItem).toHaveBeenCalled(); }); - it('should handle consent status change from PENDING to REJECTED during experiment lifecycle', () => { + it('should handle consent status change from PENDING to REJECTED during experiment lifecycle', async () => { const mockGlobal = newMockGlobal({ experimentConfig: { consentOptions: { @@ -1339,7 +1350,7 @@ describe('initializeExperiment', () => { ]), JSON.stringify(DEFAULT_PAGE_OBJECTS), ); - client.start(); + await client.start(); expect(mockExposure).toHaveBeenCalledWith('test'); @@ -1368,7 +1379,13 @@ describe('initializeExperiment', () => { parse: jest.fn().mockResolvedValue(mockCampaign), }; - MockCampaignParser.mockImplementation(() => mockCampaignParser); + mockCookieStorage = { + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + }; + + MockCampaignParser.mockImplementation(() => mockCampaignParser as any); + MockCookieStorage.mockImplementation(() => mockCookieStorage as any); }); it('should set marketing cookie directly during redirect when consent is GRANTED', async () => { @@ -1382,12 +1399,13 @@ describe('initializeExperiment', () => { mockGetGlobalScope.mockReturnValue(mockGlobal as any); mockCookieStorage = { + get: jest.fn().mockResolvedValue(undefined), set: jest.fn().mockImplementation((key, value) => { mockGlobal.document.cookie = `${key}=${JSON.stringify(value)}`; return Promise.resolve(); }), }; - MockCookieStorage.mockImplementation(() => mockCookieStorage); + MockCookieStorage.mockImplementation(() => mockCookieStorage as any); const client = DefaultWebExperimentClient.getInstance( stringify(apiKey), @@ -1444,9 +1462,10 @@ describe('initializeExperiment', () => { 'http://test.com/2', ); - expect(MockCampaignParser).toHaveBeenCalledTimes(1); - expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); - expect(MockCookieStorage).not.toHaveBeenCalled(); + + expect(MockCampaignParser).toHaveBeenCalledTimes(2); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(2); + expect(MockCookieStorage).toHaveBeenCalledTimes(1); expect(mockCookieStorage.set).not.toHaveBeenCalled(); }); @@ -1480,9 +1499,10 @@ describe('initializeExperiment', () => { 'http://test.com/2', ); - expect(MockCampaignParser).toHaveBeenCalledTimes(1); - expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); - expect(MockCookieStorage).not.toHaveBeenCalled(); + + expect(MockCampaignParser).toHaveBeenCalledTimes(2); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(2); + expect(MockCookieStorage).toHaveBeenCalledTimes(1); expect(mockCookieStorage.set).not.toHaveBeenCalled(); }); }); @@ -1512,7 +1532,7 @@ describe('initializeExperiment', () => { originalTrackSpy.mockClear(); }); - it('should track exposures immediately when consent is GRANTED', () => { + it('should track exposures immediately when consent is GRANTED', async () => { const mockGlobal = newMockGlobal({ experimentConfig: { consentOptions: { @@ -1531,7 +1551,7 @@ describe('initializeExperiment', () => { JSON.stringify(DEFAULT_PAGE_OBJECTS), ); - client.start(); + await client.start(); expect(originalTrackSpy).toHaveBeenCalled(); expect(originalTrackSpy.mock.calls[0][0]).toMatchObject({ @@ -1592,7 +1612,7 @@ describe('initializeExperiment', () => { expect(originalTrackSpy).not.toHaveBeenCalled(); }); - it('should fire all pending exposures when consent changes from PENDING to GRANTED', () => { + it('should fire all pending exposures when consent changes from PENDING to GRANTED', async () => { const mockGlobal = newMockGlobal({ experimentConfig: { consentOptions: { @@ -1615,7 +1635,7 @@ describe('initializeExperiment', () => { }), ); - client.start(); + await client.start(); expect(originalTrackSpy).not.toHaveBeenCalled(); @@ -1675,7 +1695,7 @@ describe('initializeExperiment', () => { expect(originalTrackSpy).not.toHaveBeenCalled(); }); - it('should track new exposures immediately after consent becomes GRANTED', () => { + it('should track new exposures immediately after consent becomes GRANTED', async () => { const mockGlobal = newMockGlobal({ experimentConfig: { consentOptions: { @@ -1694,7 +1714,7 @@ describe('initializeExperiment', () => { JSON.stringify(DEFAULT_PAGE_OBJECTS), ); - client.start(); + await client.start(); expect(originalTrackSpy).not.toHaveBeenCalled(); @@ -1714,7 +1734,7 @@ describe('initializeExperiment', () => { }); }); - it('should handle multiple consent status changes correctly', () => { + it('should handle multiple consent status changes correctly', async () => { const mockGlobal = newMockGlobal({ experimentConfig: { consentOptions: { @@ -1733,7 +1753,7 @@ describe('initializeExperiment', () => { JSON.stringify(DEFAULT_PAGE_OBJECTS), ); - client.start(); + await client.start(); expect(originalTrackSpy).not.toHaveBeenCalled(); @@ -1750,7 +1770,7 @@ describe('initializeExperiment', () => { expect(originalTrackSpy).toHaveBeenCalledTimes(1); }); - it('should add timestamp to exposure events', () => { + it('should add timestamp to exposure events', async () => { const mockDate = new Date('2023-01-01T00:00:00.000Z'); const mockNow = jest .spyOn(Date, 'now') @@ -1774,7 +1794,7 @@ describe('initializeExperiment', () => { JSON.stringify(DEFAULT_PAGE_OBJECTS), ); - client.start(); + await client.start(); expect(originalTrackSpy).toHaveBeenCalled(); expect(originalTrackSpy.mock.calls[0][0]).toMatchObject({ @@ -1789,7 +1809,7 @@ describe('initializeExperiment', () => { mockNow.mockRestore(); }); - it('should handle exposure tracking errors gracefully', () => { + it('should handle exposure tracking errors gracefully', async () => { const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); const errorExperimentIntegration = { track: jest.fn().mockImplementation(() => { @@ -1820,7 +1840,7 @@ describe('initializeExperiment', () => { JSON.stringify(DEFAULT_PAGE_OBJECTS), ); - expect(() => client.start()).not.toThrow(); + await expect(client.start()).resolves.not.toThrow(); expect(consoleWarnSpy).toHaveBeenCalledWith( 'Failed to track event:', @@ -1829,7 +1849,7 @@ describe('initializeExperiment', () => { consoleWarnSpy.mockRestore(); }); - it('should handle pending exposure tracking errors gracefully when consent becomes granted', () => { + it('should handle pending exposure tracking errors gracefully when consent becomes granted', async () => { const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); const errorExperimentIntegration = { track: jest.fn().mockImplementation(() => { @@ -1860,7 +1880,7 @@ describe('initializeExperiment', () => { JSON.stringify(DEFAULT_PAGE_OBJECTS), ); - client.start(); + await client.start(); expect(() => client.setConsentStatus(ConsentStatus.GRANTED), diff --git a/packages/experiment-tag/test/util/campaign.test.ts b/packages/experiment-tag/test/util/campaign.test.ts index 506f7d91..91295b5e 100644 --- a/packages/experiment-tag/test/util/campaign.test.ts +++ b/packages/experiment-tag/test/util/campaign.test.ts @@ -4,13 +4,17 @@ import { CookieStorage, getStorageKey, } from '@amplitude/analytics-core'; +import * as coreUtil from '@amplitude/experiment-core'; import { type ExperimentUser } from '@amplitude/experiment-js-client'; +import { ConsentAwareStorage } from '../../src/storage/consent-aware-storage'; +import { ConsentStatus } from '../../src/types'; import { enrichUserWithCampaignData, persistUrlParams, } from '../../src/util/campaign'; -import * as storageUtils from '../../src/util/storage'; + +const spyGetGlobalScope = jest.spyOn(coreUtil, 'getGlobalScope'); jest.mock('@amplitude/analytics-core', () => ({ Campaign: jest.fn(), @@ -20,25 +24,49 @@ jest.mock('@amplitude/analytics-core', () => ({ MKTG: 'MKTG', })); -jest.mock('../../src/util/storage', () => ({ - getStorageItem: jest.fn(), - setStorageItem: jest.fn(), -})); +const MockCampaignParser = CampaignParser as jest.MockedClass< + typeof CampaignParser +>; +const MockCookieStorage = CookieStorage as jest.MockedClass< + typeof CookieStorage +>; describe('campaign utilities', () => { + let mockGlobal: any; + let storage: ConsentAwareStorage; let mockCampaignParser: jest.Mocked; let mockCookieStorage: jest.Mocked>; - let mockGetStorageItem: jest.MockedFunction< - typeof storageUtils.getStorageItem - >; - let mockSetStorageItem: jest.MockedFunction< - typeof storageUtils.setStorageItem - >; let mockGetStorageKey: jest.MockedFunction; + const createStorageMock = () => { + let store: Record = {}; + return { + getItem: jest.fn((key: string) => store[key] || null), + setItem: jest.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: jest.fn((key: string) => { + delete store[key]; + }), + clear: jest.fn(() => { + store = {}; + }), + length: jest.fn(() => Object.keys(store).length), + key: jest.fn((index: number) => Object.keys(store)[index] || null), + }; + }; + beforeEach(() => { jest.clearAllMocks(); + mockGlobal = { + localStorage: createStorageMock(), + sessionStorage: createStorageMock(), + }; + spyGetGlobalScope.mockReturnValue(mockGlobal); + + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + mockCampaignParser = { parse: jest.fn(), } as any; @@ -48,20 +76,12 @@ describe('campaign utilities', () => { set: jest.fn(), } as any; - mockGetStorageItem = storageUtils.getStorageItem as jest.MockedFunction< - typeof storageUtils.getStorageItem - >; - mockSetStorageItem = storageUtils.setStorageItem as jest.MockedFunction< - typeof storageUtils.setStorageItem - >; mockGetStorageKey = getStorageKey as jest.MockedFunction< typeof getStorageKey >; - (CampaignParser as jest.Mock).mockImplementation(() => mockCampaignParser); - (CookieStorage as unknown as jest.Mock).mockImplementation( - () => mockCookieStorage, - ); + MockCampaignParser.mockImplementation(() => mockCampaignParser); + MockCookieStorage.mockImplementation(() => mockCookieStorage); }); describe('enrichUserWithCampaignData', () => { @@ -102,9 +122,17 @@ describe('campaign utilities', () => { mockCookieStorage.get.mockResolvedValue( persistedAmplitudeCampaign as Campaign, ); - mockGetStorageItem.mockReturnValue(persistedExperimentCampaign); + storage.setItem( + 'localStorage', + 'EXP_MKTG_test-api-k', + persistedExperimentCampaign, + ); - const result = await enrichUserWithCampaignData(apiKey, baseUser); + const result = await enrichUserWithCampaignData( + apiKey, + baseUser, + storage, + ); expect(result).toEqual({ ...baseUser, @@ -118,22 +146,14 @@ describe('campaign utilities', () => { }, }); - expect(mockGetStorageItem).toHaveBeenCalledWith( - 'localStorage', - 'EXP_MKTG_test-api-k', - ); - expect(mockSetStorageItem).toHaveBeenCalledWith( - 'localStorage', - 'EXP_MKTG_test-api-k', - { - utm_source: 'current_source', - utm_medium: 'current_medium', - utm_campaign: 'current_campaign', - utm_term: 'experiment_term', - utm_content: 'amplitude_content', - utm_id: 'experiment_id', - }, - ); + expect(storage.getItem('localStorage', 'EXP_MKTG_test-api-k')).toEqual({ + utm_source: 'current_source', + utm_medium: 'current_medium', + utm_campaign: 'current_campaign', + utm_term: 'experiment_term', + utm_content: 'amplitude_content', + utm_id: 'experiment_id', + }); }); it('should preserve lower priority values when current campaign has undefined values', async () => { @@ -158,9 +178,17 @@ describe('campaign utilities', () => { mockCookieStorage.get.mockResolvedValue( persistedAmplitudeCampaign as Campaign, ); - mockGetStorageItem.mockReturnValue(persistedExperimentCampaign); + storage.setItem( + 'localStorage', + 'EXP_MKTG_test-api-k', + persistedExperimentCampaign, + ); - const result = await enrichUserWithCampaignData(apiKey, baseUser); + const result = await enrichUserWithCampaignData( + apiKey, + baseUser, + storage, + ); expect(result.persisted_url_param).toEqual({ utm_source: 'current_source', @@ -192,9 +220,17 @@ describe('campaign utilities', () => { mockCookieStorage.get.mockResolvedValue( persistedAmplitudeCampaign as Campaign, ); - mockGetStorageItem.mockReturnValue(persistedExperimentCampaign); + storage.setItem( + 'localStorage', + 'EXP_MKTG_test-api-k', + persistedExperimentCampaign, + ); - const result = await enrichUserWithCampaignData(apiKey, baseUser); + const result = await enrichUserWithCampaignData( + apiKey, + baseUser, + storage, + ); expect(result.persisted_url_param).toEqual({ utm_source: 'test_source', @@ -226,9 +262,17 @@ describe('campaign utilities', () => { mockCookieStorage.get.mockResolvedValue( persistedAmplitudeCampaign as Campaign, ); - mockGetStorageItem.mockReturnValue(persistedExperimentCampaign); + storage.setItem( + 'localStorage', + 'EXP_MKTG_test-api-k', + persistedExperimentCampaign, + ); - const result = await enrichUserWithCampaignData(apiKey, baseUser); + const result = await enrichUserWithCampaignData( + apiKey, + baseUser, + storage, + ); expect(result.persisted_url_param).toEqual({ utm_source: 'current_source', @@ -241,14 +285,16 @@ describe('campaign utilities', () => { it('should handle empty campaign data gracefully', async () => { mockCampaignParser.parse.mockResolvedValue({} as Campaign); mockCookieStorage.get.mockResolvedValue(undefined); - mockGetStorageItem.mockReturnValue(null); - const result = await enrichUserWithCampaignData(apiKey, baseUser); + const result = await enrichUserWithCampaignData( + apiKey, + baseUser, + storage, + ); expect(result).toEqual({ ...baseUser, }); - expect(mockSetStorageItem).not.toHaveBeenCalled(); }); it('should handle all UTM parameter types', async () => { @@ -263,9 +309,12 @@ describe('campaign utilities', () => { mockCampaignParser.parse.mockResolvedValue(fullCampaign as Campaign); mockCookieStorage.get.mockResolvedValue(undefined); - mockGetStorageItem.mockReturnValue(null); - const result = await enrichUserWithCampaignData(apiKey, baseUser); + const result = await enrichUserWithCampaignData( + apiKey, + baseUser, + storage, + ); expect(result.persisted_url_param).toMatchObject({ utm_source: 'test_source', @@ -280,10 +329,9 @@ describe('campaign utilities', () => { it('should handle async errors gracefully', async () => { mockCampaignParser.parse.mockRejectedValue(new Error('Parse error')); mockCookieStorage.get.mockRejectedValue(new Error('Storage error')); - mockGetStorageItem.mockReturnValue(null); await expect( - enrichUserWithCampaignData(apiKey, baseUser), + enrichUserWithCampaignData(apiKey, baseUser, storage), ).rejects.toThrow(); }); }); @@ -298,11 +346,9 @@ describe('campaign utilities', () => { utm_campaign: 'test_campaign', }; - persistUrlParams(apiKey, campaign); + persistUrlParams(apiKey, campaign, storage); - expect(mockSetStorageItem).toHaveBeenCalledWith( - 'localStorage', - 'EXP_MKTG_test-api-k', + expect(storage.getItem('localStorage', 'EXP_MKTG_test-api-k')).toEqual( campaign, ); }); @@ -310,11 +356,9 @@ describe('campaign utilities', () => { it('should handle empty campaign object', () => { const emptyCampaign = {}; - persistUrlParams(apiKey, emptyCampaign); + persistUrlParams(apiKey, emptyCampaign, storage); - expect(mockSetStorageItem).toHaveBeenCalledWith( - 'localStorage', - 'EXP_MKTG_test-api-k', + expect(storage.getItem('localStorage', 'EXP_MKTG_test-api-k')).toEqual( emptyCampaign, ); }); @@ -334,13 +378,12 @@ describe('campaign utilities', () => { expectedPreviousCampaign as Campaign, ); mockGetStorageKey.mockReturnValue('test-storage-key'); - mockGetStorageItem.mockReturnValue(null); - await enrichUserWithCampaignData(apiKey, { user_id: 'test' }); + await enrichUserWithCampaignData(apiKey, { user_id: 'test' }, storage); - expect(CampaignParser).toHaveBeenCalledWith(); + expect(MockCampaignParser).toHaveBeenCalledWith(); expect(mockCampaignParser.parse).toHaveBeenCalledWith(); - expect(CookieStorage).toHaveBeenCalledWith(); + expect(MockCookieStorage).toHaveBeenCalledWith(); expect(getStorageKey).toHaveBeenCalledWith(apiKey, 'MKTG'); expect(mockCookieStorage.get).toHaveBeenCalledWith('test-storage-key'); }); @@ -350,11 +393,14 @@ describe('campaign utilities', () => { mockCampaignParser.parse.mockResolvedValue(expectedCampaign as Campaign); mockCookieStorage.get.mockResolvedValue(undefined); - mockGetStorageItem.mockReturnValue(null); - const result = await enrichUserWithCampaignData(apiKey, { - user_id: 'test', - }); + const result = await enrichUserWithCampaignData( + apiKey, + { + user_id: 'test', + }, + storage, + ); expect(result.persisted_url_param).toMatchObject({ utm_source: 'test', From f302249fbb62f407db988ece042bbd7cc960ad2f Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 22 Oct 2025 13:28:51 -0700 Subject: [PATCH 19/20] lint --- packages/experiment-tag/test/experiment.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index c8a46551..07137b16 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1462,7 +1462,6 @@ describe('initializeExperiment', () => { 'http://test.com/2', ); - expect(MockCampaignParser).toHaveBeenCalledTimes(2); expect(mockCampaignParser.parse).toHaveBeenCalledTimes(2); expect(MockCookieStorage).toHaveBeenCalledTimes(1); @@ -1499,7 +1498,6 @@ describe('initializeExperiment', () => { 'http://test.com/2', ); - expect(MockCampaignParser).toHaveBeenCalledTimes(2); expect(mockCampaignParser.parse).toHaveBeenCalledTimes(2); expect(MockCookieStorage).toHaveBeenCalledTimes(1); From e359275f21e872ad1abcec0b9af5215f745bfe3e Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 22 Oct 2025 13:49:06 -0700 Subject: [PATCH 20/20] add consent check on integration instead of track --- .../src/exposure/consent-aware-exposure-handler.ts | 10 ++++------ 1 file changed, 4 insertions(+), 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 8521a75b..00745837 100644 --- a/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts +++ b/packages/experiment-tag/src/exposure/consent-aware-exposure-handler.ts @@ -27,7 +27,7 @@ export class ConsentAwareExposureHandler { const experimentIntegration = globalScope?.experimentIntegration as IntegrationPlugin; if (experimentIntegration?.track) { - if (this.isTrackMethodWrapped(experimentIntegration.track)) { + if (this.isIntegrationWrapped(experimentIntegration)) { return; } @@ -35,7 +35,7 @@ export class ConsentAwareExposureHandler { experimentIntegration, ); const wrappedTrack = this.createConsentAwareTrack(this.originalTrack); - (wrappedTrack as any).__isConsentAwareWrapped = true; + (experimentIntegration as any).__isConsentAwareWrapped = true; experimentIntegration.track = wrappedTrack; } } @@ -43,10 +43,8 @@ export class ConsentAwareExposureHandler { /** * Check if a track method is already wrapped */ - private isTrackMethodWrapped( - trackMethod: (event: ExperimentEvent) => boolean, - ): boolean { - return (trackMethod as any).__isConsentAwareWrapped === true; + private isIntegrationWrapped(integration: IntegrationPlugin): boolean { + return (integration as any).__isConsentAwareWrapped === true; } /**