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 16 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
23 changes: 17 additions & 6 deletions packages/experiment-browser/src/experimentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,13 +701,24 @@ export class ExperimentClient implements Client {
return variants;
}

private async doFlags(): Promise<void> {
public async doFlags(): Promise<void> {
tyiuhc marked this conversation as resolved.
Show resolved Hide resolved
try {
const flags = await this.flagApi.getFlags({
libraryName: 'experiment-js-client',
libraryVersion: PACKAGE_VERSION,
timeoutMillis: this.config.fetchTimeoutMillis,
});
const isWebExperiment =
this.config?.['internalInstanceNameSuffix'] === 'web';
const user = this.addContext(this.getUser());
const userAndDeviceId: ExperimentUser = {
user_id: user?.user_id,
device_id: user?.device_id,
};
const flags = await this.flagApi.getFlags(
{
libraryName: 'experiment-js-client',
libraryVersion: PACKAGE_VERSION,
timeoutMillis: this.config.fetchTimeoutMillis,
},
isWebExperiment ? userAndDeviceId : undefined,
isWebExperiment ? 'web' : undefined,
);
this.flags.clear();
this.flags.putAll(flags);
} catch (e) {
Expand Down
19 changes: 17 additions & 2 deletions 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 @@ -7,8 +9,13 @@ export type GetFlagsOptions = {
evaluationMode?: string;
timeoutMillis?: number;
};

export interface FlagApi {
getFlags(options?: GetFlagsOptions): Promise<Record<string, EvaluationFlag>>;
getFlags(
options?: GetFlagsOptions,
user?: Record<string, unknown>,
deliveryMethod?: string | undefined,
tyiuhc marked this conversation as resolved.
Show resolved Hide resolved
): Promise<Record<string, EvaluationFlag>>;
}

export class SdkFlagApi implements FlagApi {
Expand All @@ -25,8 +32,11 @@ export class SdkFlagApi implements FlagApi {
this.serverUrl = serverUrl;
this.httpClient = httpClient;
}

public async getFlags(
options?: GetFlagsOptions,
user?: Record<string, unknown>,
deliveryMethod?: string | undefined,
): Promise<Record<string, EvaluationFlag>> {
const headers: Record<string, string> = {
Authorization: `Api-Key ${this.deploymentKey}`,
Expand All @@ -36,8 +46,13 @@ export class SdkFlagApi implements FlagApi {
'X-Amp-Exp-Library'
] = `${options.libraryName}/${options.libraryVersion}`;
}
if (user) {
headers['X-Amp-Exp-User'] = Base64.encodeURL(JSON.stringify(user));
}
const response = await this.httpClient.request({
requestUrl: `${this.serverUrl}/sdk/v2/flags`,
requestUrl:
`${this.serverUrl}/sdk/v2/flags` +
(deliveryMethod ? `?delivery_method=${deliveryMethod}` : ''),
method: 'GET',
headers: headers,
timeoutMillis: options?.timeoutMillis,
Expand Down
159 changes: 115 additions & 44 deletions packages/experiment-tag/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import {
} from '@amplitude/experiment-core';
import {
Experiment,
ExperimentUser,
Variant,
Variants,
AmplitudeIntegrationPlugin,
ExperimentConfig,
} from '@amplitude/experiment-js-client';
import mutate, { MutationController } from 'dom-mutator';

Expand All @@ -30,7 +30,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 @@ -42,7 +46,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 @@ -51,14 +55,21 @@ 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();
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 @@ -71,41 +82,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) => {
// force variant if in preview mode
if (
urlParams['PREVIEW'] &&
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];

if (flag?.metadata?.evaluationMode !== 'local') {
// make the remote flag locally evaluable
flag.metadata = flag.metadata || {};
flag.metadata.evaluationMode = 'local';
}
});
initialFlags = JSON.stringify(parsedFlags);
}
}

// parse through remote flags
if (flag?.metadata?.evaluationMode !== 'local') {
remoteFlagKeys.add(flag.key);
// check whether any remote flags are blocking
if (!isRemoteBlocking && flag.metadata?.isBlocking) {
isRemoteBlocking = true;
// Apply anti-flicker css if any remote flags are blocking
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);
}
}
} else {
localFlagKeys.add(flag.key);
}
});
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,
...config,
});

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

const variants = globalScope.webExperiment.all();

setUrlChangeListener();
applyVariants(variants);

// 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;
}

try {
await globalScope.webExperiment.doFlags();
// apply remote variants
applyVariants(globalScope.webExperiment.all(), remoteFlagKeys);
} catch (error) {
console.warn('Error fetching remote flags:', error);
tyiuhc marked this conversation as resolved.
Show resolved Hide resolved
}
};

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 @@ -142,6 +210,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
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