Skip to content

feat: Web experiment remote evaluation #138

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

Merged
merged 24 commits into from
Jan 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
34c2cb6
update doFlags to take user object, update tag script to fetch remote…
tyiuhc Nov 5, 2024
a463740
fix applyVariants logic
tyiuhc Nov 6, 2024
348d857
create remote flag fetch test and clean up exisiting tests
tyiuhc Nov 13, 2024
936f27a
refactor unit tests code
tyiuhc Nov 13, 2024
27b71cd
add unit tests
tyiuhc Nov 13, 2024
987031e
update unit tests, update getFlags with deliveryMethod arg
tyiuhc Nov 18, 2024
f729fe4
fix lint
tyiuhc Nov 18, 2024
a93d63a
fix tests
tyiuhc Nov 18, 2024
6a91800
fix doFlags
tyiuhc Nov 18, 2024
d8a5109
fix web remote eval preview unit test
tyiuhc Nov 18, 2024
c40f7ed
remove unused util
tyiuhc Nov 19, 2024
7db6dd4
remove unused import
tyiuhc Nov 19, 2024
2285a9d
fix doFlags user creation
tyiuhc Nov 20, 2024
eea5ef4
fix web_exp_id generation for backwards compatability
tyiuhc Nov 20, 2024
a379647
nit: formatting
tyiuhc Nov 20, 2024
a2ad01e
refactor parsing initial flags, add antiflicker for remote blocking f…
tyiuhc Nov 25, 2024
bfce7c6
update getflags options, exclude x-amp-exp-user header when no user/d…
tyiuhc Nov 27, 2024
1a72974
Merge branch 'main' into web-remote-eval
tyiuhc Nov 27, 2024
ac60b53
fix: test
tyiuhc Nov 27, 2024
e8b064f
refactor and add comment for setting IDs
tyiuhc Dec 16, 2024
c8bd924
make all remote flags locally evaluable, only applyVariants present i…
tyiuhc Dec 18, 2024
b7a4cb1
Merge branch 'main' into web-remote-eval
tyiuhc Dec 18, 2024
2231792
add server zone config
tyiuhc Jan 3, 2025
6d07684
update backwards compatibility
tyiuhc Jan 6, 2025
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
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
Loading