Skip to content
13 changes: 13 additions & 0 deletions .github/workflows/publish-to-s3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ on:
type: boolean
required: true
default: true
includeChromeExtension:
description: 'Release experiment-tag package for chrome extension'
type: boolean
required: true
default: true

jobs:
authorize:
Expand Down Expand Up @@ -89,6 +94,14 @@ jobs:
PACKAGES="segment-plugin"
fi
fi

if [[ "${{ github.event.inputs.includeChromeExtension }}" == "true" ]]; then
if [[ -n "$PACKAGES" ]]; then
PACKAGES="$PACKAGES,chrome-extension"
else
PACKAGES="chrome-extension"
fi
fi

if [[ -z "$PACKAGES" ]]; then
echo "No packages selected for upload"
Expand Down
154 changes: 75 additions & 79 deletions packages/experiment-tag/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ import {
PageObject,
PageObjects,
PreviewVariantsOptions,
PreviewState,
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';
Expand All @@ -53,8 +55,7 @@ import { convertEvaluationVariantToVariant } from './util/variant';
const MUTATE_ACTION = 'mutate';
export const INJECT_ACTION = 'inject';
const REDIRECT_ACTION = 'redirect';
const PREVIEW_MODE_PARAM = 'PREVIEW';
export const PREVIEW_SEGMENT_NAME = 'Preview';
export const PREVIEW_MODE_PARAM = 'PREVIEW';
export const PREVIEW_MODE_SESSION_KEY = 'amp-preview-mode';
const VISUAL_EDITOR_PARAM = 'VISUAL_EDITOR';

Expand Down Expand Up @@ -83,6 +84,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
[flagKey: string]: string | undefined; // variant
};
} = {};
// Also used by chrome extension
private flagVariantMap: {
[flagKey: string]: {
[variantKey: string]: Variant;
Expand All @@ -98,7 +100,9 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
private activePages: PageObjects = {};
private subscriptionManager: SubscriptionManager | undefined;
private isVisualEditorMode = false;
private previewFlags: Record<string, string> = {};
// Preview mode is set by url params, postMessage or session storage, not chrome extension
isPreviewMode = false;
previewFlags: Record<string, string> = {};

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

const urlParams = getUrlParams();

let previewFlags: Record<string, string> = {};
// explicit URL params takes precedence over session storage
if (urlParams[PREVIEW_MODE_PARAM]) {
Object.keys(urlParams).forEach((key) => {
if (key !== 'PREVIEW' && urlParams[key]) {
previewFlags[key] = urlParams[key];
}
});
} else {
previewFlags =
getStorageItem('sessionStorage', PREVIEW_MODE_SESSION_KEY) || {};
}

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

Expand All @@ -147,48 +136,11 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
convertEvaluationVariantToVariant(variants[variantKey]);
});

// Update initialFlags to force variant if in preview mode
if (key in previewFlags && previewFlags[key] in variants) {
this.previewFlags[key] = previewFlags[key];

const previewSegment = {
metadata: { segmentName: PREVIEW_SEGMENT_NAME },
variant: previewFlags[key],
};

flag.segments = [previewSegment];
metadata.evaluationMode = 'local';
}

if (metadata.evaluationMode !== 'local') {
this.remoteFlagKeys.push(key);
}

flag.metadata = metadata;
});

if (Object.keys(this.previewFlags).length > 0) {
if (urlParams[PREVIEW_MODE_PARAM]) {
setStorageItem(
'sessionStorage',
PREVIEW_MODE_SESSION_KEY,
this.previewFlags,
);
const previewParamsToRemove = [
...Object.keys(this.previewFlags),
PREVIEW_MODE_PARAM,
];
this.globalScope.history.replaceState(
{},
'',
removeQueryParams(
this.globalScope.location.href,
previewParamsToRemove,
),
);
}
}

const initialFlagsString = JSON.stringify(this.initialFlags);

// initialize the experiment
Expand Down Expand Up @@ -234,6 +186,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
},
this.globalScope,
);
this.setupPreviewMode(urlParams);
this.subscriptionManager.initSubscriptions();

// if in visual edit mode, remove the query param
Expand Down Expand Up @@ -283,11 +236,14 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
if (
this.remoteFlagKeys.includes(flagKey) &&
variant.metadata?.blockingEvaluation &&
Object.keys(this.activePages).includes(flagKey)
Object.keys(this.activePages).includes(flagKey) &&
!this.remoteFlagKeys.every((key) =>
Object.keys(this.previewFlags).includes(key),
)
) {
this.isRemoteBlocking = true;
// Apply anti-flicker CSS to prevent UI flicker
this.applyAntiFlickerCss();
applyAntiFlickerCss();
}
}

Expand All @@ -311,8 +267,16 @@ export class DefaultWebExperimentClient implements WebExperimentClient {

// apply local variants
this.applyVariants({ flagKeys: this.localFlagKeys });
this.previewVariants({
keyToVariant: this.previewFlags,
});

if (this.remoteFlagKeys.length === 0) {
if (
// do not fetch remote flags if all remote flags are in preview mode
this.remoteFlagKeys.every((key) =>
Object.keys(this.previewFlags).includes(key),
)
) {
this.isRunning = true;
return;
}
Expand Down Expand Up @@ -369,11 +333,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
* @param options
*/
public applyVariants(options?: ApplyVariantsOptions) {
if (Object.keys(this.previewFlags).length > 0) {
showPreviewModeModal({
flags: this.previewFlags,
});
}
const { flagKeys } = options || {};
const variants = this.getVariants();
if (Object.keys(variants).length === 0) {
Expand All @@ -391,7 +350,8 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
this.fireStoredRedirectImpressions();

for (const key in variants) {
if (flagKeys && !flagKeys.includes(key)) {
// preview actions are handled by previewVariants
if ((flagKeys && !flagKeys.includes(key)) || this.previewFlags[key]) {
continue;
}
const variant = variants[key];
Expand Down Expand Up @@ -486,6 +446,12 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
if (!variantObject) {
return;
}
if (this.isPreviewMode) {
this.exposureWithDedupe(key, variantObject, true);
showPreviewModeModal({
flags: this.previewFlags,
});
}
const payload = variantObject.payload;
if (!payload || !Array.isArray(payload)) {
return;
Expand Down Expand Up @@ -791,24 +757,11 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
} else {
this.experimentClient.exposure(key);
}
this.urlExposureCache[currentUrl][key] = variant.key;
}
}

private applyAntiFlickerCss() {
if (!this.globalScope.document.getElementById('amp-exp-css')) {
const id = 'amp-exp-css';
const s = document.createElement('style');
s.id = id;
s.innerText =
'* { visibility: hidden !important; background-image: none !important; }';
document.head.appendChild(s);
this.globalScope.window.setTimeout(function () {
s.remove();
}, 1000);
(this.urlExposureCache[currentUrl] ??= {})[key] = variant.key;
}
}

// Also used by chrome extension
updateActivePages(flagKey: string, page: PageObject, isActive: boolean) {
if (!this.activePages[flagKey]) {
this.activePages[flagKey] = {};
Expand Down Expand Up @@ -894,4 +847,47 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
removeStorageItem('sessionStorage', redirectStorageKey);
}
}

private setupPreviewMode(urlParams: Record<string, string>) {
// explicit URL params takes precedence over session storage
if (urlParams[PREVIEW_MODE_PARAM] === 'true') {
Object.keys(urlParams).forEach((key) => {
if (key !== PREVIEW_MODE_PARAM && urlParams[key]) {
this.previewFlags[key] = urlParams[key];
}
});

setStorageItem('sessionStorage', PREVIEW_MODE_SESSION_KEY, {
previewFlags: this.previewFlags,
});
const previewParamsToRemove = [
...Object.keys(this.previewFlags),
PREVIEW_MODE_PARAM,
];
this.globalScope.history.replaceState(
{},
'',
removeQueryParams(
this.globalScope.location.href,
previewParamsToRemove,
),
);
// if in preview mode, listen for ForceVariant messages
WindowMessenger.setup();
} else {
const previewState: PreviewState | null = getStorageItem(
'sessionStorage',
PREVIEW_MODE_SESSION_KEY,
);
if (previewState) {
this.previewFlags = previewState.previewFlags;
}
}

if (Object.keys(this.previewFlags).length > 0) {
this.isPreviewMode = true;
} else {
return;
}
}
}
48 changes: 42 additions & 6 deletions packages/experiment-tag/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,61 @@
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 { applyAntiFlickerCss } from './util/anti-flicker';
import { isPreviewMode } from './util/url';

export const initialize = (
apiKey: string,
initialFlags: string,
pageObjects: string,
config: WebExperimentConfig,
): void => {
DefaultWebExperimentClient.getInstance(
apiKey,
initialFlags,
pageObjects,
config,
)
const shouldFetchConfigs =
isPreviewMode() || getGlobalScope()?.WebExperiment.injectedByExtension;

if (shouldFetchConfigs) {
applyAntiFlickerCss();
fetchLatestConfigs(apiKey, config.serverZone)
.then((previewState) => {
const flags = JSON.stringify(previewState.flags);
const objects = JSON.stringify(previewState.pageViewObjects);
startClient(apiKey, flags, objects, config);
})
.catch((error) => {
console.warn('Failed to fetch latest configs for preview:', error);
startClient(apiKey, initialFlags, pageObjects, config);
});
} else {
startClient(apiKey, initialFlags, pageObjects, config);
}
};

const startClient = (
apiKey: string,
flags: string,
objects: string,
config: WebExperimentConfig,
): void => {
DefaultWebExperimentClient.getInstance(apiKey, flags, objects, config)
.start()
.finally(() => {
// Remove anti-flicker css if it exists
document.getElementById('amp-exp-css')?.remove();
});
};

const fetchLatestConfigs = async (apiKey: string, serverZone?: string) => {
const serverUrl =
serverZone === 'EU'
? 'https://api.lab.eu.amplitude.com'
: 'https://api.lab.amplitude.com';
const api = new SdkPreviewApi(apiKey, serverUrl, HttpClient);
return api.getPreviewFlagsAndPageViewObjects();
};

export {
ApplyVariantsOptions,
RevertVariantsOptions,
Expand Down
Loading