Skip to content

Commit

Permalink
feat: Web experiment remote evaluation (#138)
Browse files Browse the repository at this point in the history
  • Loading branch information
tyiuhc authored Jan 6, 2025
1 parent 35aa29c commit d7c167f
Show file tree
Hide file tree
Showing 7 changed files with 598 additions and 546 deletions.
8 changes: 8 additions & 0 deletions packages/experiment-browser/src/experimentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,10 +710,18 @@ export class ExperimentClient implements Client {

private async doFlags(): Promise<void> {
try {
const isWebExperiment =
this.config?.['internalInstanceNameSuffix'] === 'web';
const user = this.addContext(this.getUser());
const flags = await this.flagApi.getFlags({
libraryName: 'experiment-js-client',
libraryVersion: PACKAGE_VERSION,
timeoutMillis: this.config.fetchTimeoutMillis,
deliveryMethod: isWebExperiment ? 'web' : undefined,
user:
isWebExperiment && (user?.user_id || user?.device_id)
? { user_id: user?.user_id, device_id: user?.device_id }
: undefined,
});
this.flags.clear();
this.flags.putAll(flags);
Expand Down
17 changes: 16 additions & 1 deletion packages/experiment-core/src/api/flag-api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Base64 } from 'js-base64';

import { EvaluationFlag } from '../evaluation/flag';
import { HttpClient } from '../transport/http';

Expand All @@ -6,7 +8,10 @@ export type GetFlagsOptions = {
libraryVersion: string;
evaluationMode?: string;
timeoutMillis?: number;
user?: Record<string, unknown>;
deliveryMethod?: string | undefined;
};

export interface FlagApi {
getFlags(options?: GetFlagsOptions): Promise<Record<string, EvaluationFlag>>;
}
Expand All @@ -25,6 +30,7 @@ export class SdkFlagApi implements FlagApi {
this.serverUrl = serverUrl;
this.httpClient = httpClient;
}

public async getFlags(
options?: GetFlagsOptions,
): Promise<Record<string, EvaluationFlag>> {
Expand All @@ -36,8 +42,17 @@ export class SdkFlagApi implements FlagApi {
'X-Amp-Exp-Library'
] = `${options.libraryName}/${options.libraryVersion}`;
}
if (options?.user) {
headers['X-Amp-Exp-User'] = Base64.encodeURL(
JSON.stringify(options.user),
);
}
const response = await this.httpClient.request({
requestUrl: `${this.serverUrl}/sdk/v2/flags`,
requestUrl:
`${this.serverUrl}/sdk/v2/flags` +
(options?.deliveryMethod
? `?delivery_method=${options.deliveryMethod}`
: ''),
method: 'GET',
headers: headers,
timeoutMillis: options?.timeoutMillis,
Expand Down
186 changes: 138 additions & 48 deletions packages/experiment-tag/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ import {
import { safeGlobal } from '@amplitude/experiment-core';
import {
Experiment,
ExperimentUser,
Variant,
Variants,
AmplitudeIntegrationPlugin,
ExperimentConfig,
} from '@amplitude/experiment-js-client';
import * as FeatureExperiment from '@amplitude/experiment-js-client';
import mutate, { MutationController } from 'dom-mutator';
Expand All @@ -34,7 +34,11 @@ let previousUrl: string | undefined;
// Cache to track exposure for the current URL, should be cleared on URL change
let urlExposureCache: { [url: string]: { [key: string]: string | undefined } };

export const initializeExperiment = (apiKey: string, initialFlags: string) => {
export const initializeExperiment = async (
apiKey: string,
initialFlags: string,
config: ExperimentConfig = {},
) => {
const globalScope = getGlobalScope();
if (globalScope?.webExperiment) {
return;
Expand All @@ -46,7 +50,7 @@ export const initializeExperiment = (apiKey: string, initialFlags: string) => {
previousUrl = undefined;
urlExposureCache = {};
const experimentStorageName = `EXP_${apiKey.slice(0, 10)}`;
let user: ExperimentUser;
let user;
try {
user = JSON.parse(
globalScope.localStorage.getItem(experimentStorageName) || '{}',
Expand All @@ -55,14 +59,24 @@ export const initializeExperiment = (apiKey: string, initialFlags: string) => {
user = {};
}

// create new user if it does not exist, or it does not have device_id
if (Object.keys(user).length === 0 || !user.device_id) {
user = {};
user.device_id = UUID();
globalScope.localStorage.setItem(
experimentStorageName,
JSON.stringify(user),
);
// create new user if it does not exist, or it does not have device_id or web_exp_id
if (Object.keys(user).length === 0 || !user.device_id || !user.web_exp_id) {
if (!user.device_id || !user.web_exp_id) {
// if user has device_id, migrate it to web_exp_id
if (user.device_id) {
user.web_exp_id = user.device_id;
} else if (user.web_exp_id) {
user.device_id = user.web_exp_id;
} else {
const uuid = UUID();
// both IDs are set for backwards compatibility, to be removed in future update
user = { device_id: uuid, web_exp_id: uuid };
}
globalScope.localStorage.setItem(
experimentStorageName,
JSON.stringify(user),
);
}
}

const urlParams = getUrlParams();
Expand All @@ -75,41 +89,78 @@ export const initializeExperiment = (apiKey: string, initialFlags: string) => {
);
return;
}
// force variant if in preview mode
if (urlParams['PREVIEW']) {
const parsedFlags = JSON.parse(initialFlags);
parsedFlags.forEach((flag: EvaluationFlag) => {
if (flag.key in urlParams && urlParams[flag.key] in flag.variants) {
// Strip the preview query param
globalScope.history.replaceState(
{},
'',
removeQueryParams(globalScope.location.href, ['PREVIEW', flag.key]),
);

// Keep page targeting segments
const pageTargetingSegments = flag.segments.filter((segment) =>
isPageTargetingSegment(segment),
);

// Create or update the preview segment
const previewSegment = {
metadata: { segmentName: 'preview' },
variant: urlParams[flag.key],
};

flag.segments = [...pageTargetingSegments, previewSegment];

let isRemoteBlocking = false;
const remoteFlagKeys: Set<string> = new Set();
const localFlagKeys: Set<string> = new Set();
const parsedFlags = JSON.parse(initialFlags);

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

// Force variant if in preview mode
if (
urlParams['PREVIEW'] &&
key in urlParams &&
urlParams[key] in variants
) {
// Remove preview-related query parameters from the URL
globalScope.history.replaceState(
{},
'',
removeQueryParams(globalScope.location.href, ['PREVIEW', key]),
);

// Retain only page-targeting segments
const pageTargetingSegments = segments.filter(isPageTargetingSegment);

// Add or update the preview segment
const previewSegment = {
metadata: { segmentName: 'preview' },
variant: urlParams[key],
};

// Update the flag's segments to include the preview segment
flag.segments = [...pageTargetingSegments, previewSegment];

// make all preview flags local
metadata.evaluationMode = 'local';
}

if (metadata.evaluationMode !== 'local') {
remoteFlagKeys.add(key);

// allow local evaluation for remote flags
metadata.evaluationMode = 'local';

// Check if any remote flags are blocking
if (!isRemoteBlocking && metadata.blockingEvaluation) {
isRemoteBlocking = true;

// Apply anti-flicker CSS to prevent UI flicker
applyAntiFlickerCss();
}
});
initialFlags = JSON.stringify(parsedFlags);
}
} else {
// Add locally evaluable flags to the local flag set
localFlagKeys.add(key);
}

flag.metadata = metadata;
});

initialFlags = JSON.stringify(parsedFlags);

// initialize the experiment
globalScope.webExperiment = Experiment.initialize(apiKey, {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
internalInstanceNameSuffix: 'web',
fetchOnStart: false,
initialFlags: initialFlags,
// timeout for fetching remote flags
fetchTimeoutMillis: 1000,
pollOnStart: false,
fetchOnStart: false,
...config,
});

// If no integration has been set, use an amplitude integration.
Expand All @@ -125,14 +176,34 @@ export const initializeExperiment = (apiKey: string, initialFlags: string) => {
globalScope.webExperiment.addPlugin(globalScope.experimentIntegration);
globalScope.webExperiment.setUser(user);

const variants = globalScope.webExperiment.all();
setUrlChangeListener(new Set([...localFlagKeys, ...remoteFlagKeys]));

// apply local variants
applyVariants(globalScope.webExperiment.all(), localFlagKeys);

if (!isRemoteBlocking) {
// Remove anti-flicker css if remote flags are not blocking
globalScope.document.getElementById?.('amp-exp-css')?.remove();
}

if (remoteFlagKeys.size === 0) {
return;
}

setUrlChangeListener();
applyVariants(variants);
try {
await globalScope.webExperiment.doFlags();
} catch (error) {
console.warn('Error fetching remote flags:', error);
}
// apply remote variants - if fetch is unsuccessful, fallback order: 1. localStorage flags, 2. initial flags
applyVariants(globalScope.webExperiment.all(), remoteFlagKeys);
};

const applyVariants = (variants: Variants | undefined) => {
if (!variants) {
const applyVariants = (
variants: Variants,
flagKeys: Set<string> | undefined = undefined,
) => {
if (Object.keys(variants).length === 0) {
return;
}
const globalScope = getGlobalScope();
Expand All @@ -146,6 +217,9 @@ const applyVariants = (variants: Variants | undefined) => {
urlExposureCache[currentUrl] = {};
}
for (const key in variants) {
if (flagKeys && !flagKeys.has(key)) {
continue;
}
const variant = variants[key];
const isWebExperimentation = variant.metadata?.deliveryMethod === 'web';
if (isWebExperimentation) {
Expand Down Expand Up @@ -296,15 +370,15 @@ const handleInject = (action, key: string, variant: Variant) => {
exposureWithDedupe(key, variant);
};

export const setUrlChangeListener = () => {
export const setUrlChangeListener = (flagKeys: Set<string>) => {
const globalScope = getGlobalScope();
if (!globalScope) {
return;
}
// Add URL change listener for back/forward navigation
globalScope.addEventListener('popstate', () => {
revertMutations();
applyVariants(globalScope.webExperiment.all());
applyVariants(globalScope.webExperiment.all(), flagKeys);
});

// Create wrapper functions for pushState and replaceState
Expand All @@ -318,7 +392,7 @@ export const setUrlChangeListener = () => {
const result = originalPushState.apply(this, args);
// Revert mutations and apply variants
revertMutations();
applyVariants(globalScope.webExperiment.all());
applyVariants(globalScope.webExperiment.all(), flagKeys);
previousUrl = globalScope.location.href;
return result;
};
Expand All @@ -329,7 +403,7 @@ export const setUrlChangeListener = () => {
const result = originalReplaceState.apply(this, args);
// Revert mutations and apply variants
revertMutations();
applyVariants(globalScope.webExperiment.all());
applyVariants(globalScope.webExperiment.all(), flagKeys);
previousUrl = globalScope.location.href;
return result;
};
Expand Down Expand Up @@ -364,3 +438,19 @@ const exposureWithDedupe = (key: string, variant: Variant) => {
urlExposureCache[currentUrl][key] = variant.key;
}
};

const applyAntiFlickerCss = () => {
const globalScope = getGlobalScope();
if (!globalScope) return;
if (!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);
globalScope.window.setTimeout(function () {
s.remove();
}, 1000);
}
};
11 changes: 8 additions & 3 deletions packages/experiment-tag/src/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { initializeExperiment } from './experiment';

const API_KEY = '{{DEPLOYMENT_KEY}}';
const initialFlags = '{{INITIAL_FLAGS}}';
initializeExperiment(API_KEY, initialFlags);
// Remove anti-flicker css if it exists
document.getElementById('amp-exp-css')?.remove();
const serverZone = '{{SERVER_ZONE}}';

initializeExperiment(API_KEY, initialFlags, { serverZone: serverZone }).then(
() => {
// Remove anti-flicker css if it exists
document.getElementById('amp-exp-css')?.remove();
},
);
Loading

0 comments on commit d7c167f

Please sign in to comment.