Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Web experiment remote evaluation #138

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
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
184 changes: 136 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,22 @@ 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 {
const uuid = UUID();
// both IDs are set for backwards compatibility, to be removed in future update
user = { device_id: uuid, web_exp_id: uuid };
tyiuhc marked this conversation as resolved.
Show resolved Hide resolved
}
globalScope.localStorage.setItem(
experimentStorageName,
JSON.stringify(user),
);
}
}

const urlParams = getUrlParams();
Expand All @@ -75,41 +87,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 +174,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);
tyiuhc marked this conversation as resolved.
Show resolved Hide resolved
}
// 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 +215,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 +368,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 +390,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 +401,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 +436,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);
}
};
7 changes: 4 additions & 3 deletions packages/experiment-tag/src/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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();
initializeExperiment(API_KEY, initialFlags).then(() => {
// Remove anti-flicker css if it exists
document.getElementById('amp-exp-css')?.remove();
});
Loading
Loading