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

Merged
merged 24 commits into from
Jan 6, 2025
Merged
Show file tree
Hide file tree
Changes from 16 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
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