Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 32 additions & 19 deletions packages/experiment-tag/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ import mutate, { MutationController } from 'dom-mutator';

import { MessageBus } from './message-bus';
import { showPreviewModeModal } from './preview/preview';
import { ConsentAwareStorage } from './storage/consent-aware-storage';
import { PageChangeEvent, SubscriptionManager } from './subscriptions';
import {
ConsentOptions,
ConsentStatus,
Defaults,
WebExperimentClient,
WebExperimentConfig,
Expand All @@ -33,15 +36,9 @@ 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';
import {
getStorageItem,
setStorageItem,
removeStorageItem,
} from './util/storage';
import {
getUrlParams,
removeQueryParams,
Expand Down Expand Up @@ -103,6 +100,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
// Preview mode is set by url params, postMessage or session storage, not chrome extension
isPreviewMode = false;
previewFlags: Record<string, string> = {};
private consentOptions: ConsentOptions = {
status: ConsentStatus.GRANTED,
};
private storage: ConsentAwareStorage;

constructor(
apiKey: string,
Expand All @@ -127,6 +128,12 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
...(this.globalScope.experimentConfig ?? {}),
};

if (this.config.consentOptions) {
this.consentOptions = this.config.consentOptions;
}

this.storage = new ConsentAwareStorage(this.consentOptions.status);

this.initialFlags.forEach((flag: EvaluationFlag) => {
const { key, variants, metadata = {} } = flag;

Expand Down Expand Up @@ -175,7 +182,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,
Expand Down Expand Up @@ -210,7 +218,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {

const experimentStorageName = `EXP_${this.apiKey.slice(0, 10)}`;
const user =
getStorageItem<WebExperimentUser>(
this.storage.getItem<WebExperimentUser>(
'localStorage',
experimentStorageName,
) || {};
Expand All @@ -222,10 +230,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
Expand Down Expand Up @@ -524,6 +532,11 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
this.customRedirectHandler = handler;
}

public setConsentStatus(consentStatus: ConsentStatus) {
this.consentOptions.status = consentStatus;
this.storage.setConsentStatus(consentStatus);
}

private async fetchRemoteFlags() {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
Expand Down Expand Up @@ -578,7 +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;
setMarketingCookie(this.apiKey).then();
this.storage.setMarketingCookie(this.apiKey).then();
// perform redirection
if (this.customRedirectHandler) {
this.customRedirectHandler(targetUrl);
Expand Down Expand Up @@ -799,16 +812,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) {
Expand All @@ -833,18 +846,18 @@ 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,
redirects[storedFlagKey].variant,
true,
);
}
removeStorageItem('sessionStorage', redirectStorageKey);
this.storage.removeItem('sessionStorage', redirectStorageKey);
}, 500);
} else {
removeStorageItem('sessionStorage', redirectStorageKey);
this.storage.removeItem('sessionStorage', redirectStorageKey);
}
}

Expand All @@ -857,7 +870,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 = [
Expand All @@ -875,7 +888,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,
);
Expand Down
8 changes: 7 additions & 1 deletion packages/experiment-tag/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -13,6 +13,12 @@ export const initialize = (
pageObjects: string,
config: WebExperimentConfig,
): void => {
if (
getGlobalScope()?.experimentConfig.consentOptions.status ===
ConsentStatus.REJECTED
) {
return;
}
const shouldFetchConfigs =
isPreviewMode() || getGlobalScope()?.WebExperiment.injectedByExtension;

Expand Down
140 changes: 140 additions & 0 deletions packages/experiment-tag/src/storage/consent-aware-storage.ts
Original file line number Diff line number Diff line change
@@ -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<StorageType, Map<string, unknown>> = new Map();
private inMemoryMarketingCookies: Map<string, Campaign> = 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<void> {
for (const [
storageKey,
campaign,
] of this.inMemoryMarketingCookies.entries()) {
try {
const cookieStorage = new CookieStorage<Campaign>({
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<T>(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<void> {
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<Campaign>({
sameSite: 'Lax',
});
await cookieStorage.set(storageKey, campaign);
} else {
this.inMemoryMarketingCookies.set(storageKey, campaign);
}
} catch (error) {
console.warn('Failed to set marketing cookie:', error);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const removeStorageItem = (
}
};

const getStorage = (storageType: StorageType): Storage | null => {
export const getStorage = (storageType: StorageType): Storage | null => {
const globalScope = getGlobalScope();
if (!globalScope) {
return null;
Expand Down
15 changes: 14 additions & 1 deletion packages/experiment-tag/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,25 @@ 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
* 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
*/
useDefaultNavigationHandler?: boolean;
consentOptions?: ConsentOptions;
}

export const Defaults: WebExperimentConfig = {
Expand Down Expand Up @@ -79,6 +90,8 @@ export interface WebExperimentClient {
getActivePages(): PageObjects;

setRedirectHandler(handler: (url: string) => void): void;

setConsentStatus(consentStatus: ConsentStatus): void;
}

export type WebExperimentUser = {
Expand Down
19 changes: 0 additions & 19 deletions packages/experiment-tag/src/util/cookie.ts

This file was deleted.

Loading