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
2 changes: 1 addition & 1 deletion packages/experiment-browser/src/experimentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
7 changes: 6 additions & 1 deletion packages/experiment-browser/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
});
};

Expand Down
14 changes: 11 additions & 3 deletions packages/experiment-browser/src/providers/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions packages/experiment-browser/src/types/exposure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export type Exposure = {
* evaluation for the user. Used for system purposes.
*/
metadata?: Record<string, unknown>;
/**
* (Optional) The time the exposure occurred.
*/
time?: number;
};

/**
Expand Down
112 changes: 84 additions & 28 deletions packages/experiment-tag/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,29 @@ 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 {
ConsentAwareLocalStorage,
ConsentAwareSessionStorage,
ConsentAwareStorage,
} from './storage/consent-aware-storage';
import {
getExperimentStorageKey,
getPreviewModeSessionKey,
getRedirectStorageKey,
getVisualEditorSessionKey,
} from './storage/keys';
import {
deletePersistedData,
getAndParseStorageItem,
setAndStringifyStorageItem,
} from './storage/storage';
import { PageChangeEvent, SubscriptionManager } from './subscriptions';
import {
ConsentOptions,
ConsentStatus,
Defaults,
WebExperimentClient,
WebExperimentConfig,
Expand All @@ -34,15 +53,9 @@ import {
} from './types';
import { applyAntiFlickerCss } from './util/anti-flicker';
import { enrichUserWithCampaignData } from './util/campaign';
import { setMarketingCookie } from './util/cookie';
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 {
getStorageItem,
setStorageItem,
removeStorageItem,
} from './util/storage';
import {
getUrlParams,
removeQueryParams,
Expand All @@ -57,7 +70,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;
Expand Down Expand Up @@ -104,6 +116,11 @@ 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;
private consentAwareExposureHandler: ConsentAwareExposureHandler;

constructor(
apiKey: string,
Expand All @@ -128,6 +145,16 @@ 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.consentAwareExposureHandler = new ConsentAwareExposureHandler(
this.consentOptions.status,
);

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

Expand All @@ -149,6 +176,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,
Expand Down Expand Up @@ -176,7 +207,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', getVisualEditorSessionKey()) !==
null;
this.subscriptionManager = new SubscriptionManager(
this,
this.messageBus,
Expand Down Expand Up @@ -209,9 +241,9 @@ 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 =
getStorageItem<WebExperimentUser>(
this.storage.getItem<WebExperimentUser>(
'localStorage',
experimentStorageName,
) || {};
Expand All @@ -223,10 +255,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 All @@ -248,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) {
Expand All @@ -260,6 +296,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
);
}
this.globalScope.experimentIntegration.type = 'integration';
this.consentAwareExposureHandler.wrapExperimentIntegrationTrack();
this.experimentClient.addPlugin(this.globalScope.experimentIntegration);
this.experimentClient.setUser(enrichedUser);

Expand Down Expand Up @@ -527,6 +564,21 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
this.customRedirectHandler = handler;
}

public setConsentStatus(consentStatus: ConsentStatus) {
if (
consentStatus == undefined ||
consentStatus === this.consentOptions.status
) {
return;
}
this.consentOptions.status = consentStatus;
this.storage.setConsentStatus(consentStatus);
if (consentStatus === ConsentStatus.REJECTED) {
deletePersistedData(this.apiKey, this.config);
}
this.consentAwareExposureHandler.setConsentStatus(consentStatus);
}

private async fetchRemoteFlags() {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
Expand Down Expand Up @@ -581,7 +633,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,19 +851,19 @@ 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 =
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 redirectStorageKey = getRedirectStorageKey(this.apiKey);
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 @@ -836,18 +888,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 @@ -860,9 +912,13 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
}
});

setStorageItem('sessionStorage', PREVIEW_MODE_SESSION_KEY, {
previewFlags: this.previewFlags,
});
setAndStringifyStorageItem<PreviewState>(
'sessionStorage',
getPreviewModeSessionKey(),
{
previewFlags: this.previewFlags,
},
);
const previewParamsToRemove = [
...Object.keys(this.previewFlags),
PREVIEW_MODE_PARAM,
Expand All @@ -878,9 +934,9 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
// if in preview mode, listen for ForceVariant messages
WindowMessenger.setup();
} else {
const previewState: PreviewState | null = getStorageItem(
const previewState = getAndParseStorageItem<PreviewState>(
'sessionStorage',
PREVIEW_MODE_SESSION_KEY,
getPreviewModeSessionKey(),
);
if (previewState) {
this.previewFlags = previewState.previewFlags;
Expand Down
Loading