@amplitude/experiment-js-client
Version:
Amplitude Experiment Javascript Client SDK
1,425 lines (1,400 loc) • 85.5 kB
JavaScript
/* @amplitude/experiment-js-client v1.20.1 - For license info see https://app.unpkg.com/@amplitude/experiment-js-client@1.20.1/files/LICENSE */
import { safeGlobal, TimeoutError, isLocalStorageAvailable, getGlobalScope, EvaluationEngine, Poller, SdkFlagApi, SdkEvaluationApi, topologicalSort, FetchError } from '@amplitude/experiment-core';
import { AnalyticsConnector } from '@amplitude/analytics-connector';
import { UAParser } from '@amplitude/ua-parser-js';
/**
* @deprecated Update your version of the amplitude analytics-js SDK to 8.17.0+ and for seamless
* integration with the amplitude analytics SDK.
*/
class AmplitudeUserProvider {
constructor(amplitudeInstance) {
this.amplitudeInstance = amplitudeInstance;
}
getUser() {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
return {
device_id: (_b = (_a = this.amplitudeInstance) === null || _a === void 0 ? void 0 : _a.options) === null || _b === void 0 ? void 0 : _b.deviceId,
user_id: (_d = (_c = this.amplitudeInstance) === null || _c === void 0 ? void 0 : _c.options) === null || _d === void 0 ? void 0 : _d.userId,
version: (_f = (_e = this.amplitudeInstance) === null || _e === void 0 ? void 0 : _e.options) === null || _f === void 0 ? void 0 : _f.versionName,
language: (_h = (_g = this.amplitudeInstance) === null || _g === void 0 ? void 0 : _g.options) === null || _h === void 0 ? void 0 : _h.language,
platform: (_k = (_j = this.amplitudeInstance) === null || _j === void 0 ? void 0 : _j.options) === null || _k === void 0 ? void 0 : _k.platform,
os: this.getOs(),
device_model: this.getDeviceModel(),
};
}
getOs() {
var _a, _b, _c, _d, _e, _f;
return [
(_c = (_b = (_a = this.amplitudeInstance) === null || _a === void 0 ? void 0 : _a._ua) === null || _b === void 0 ? void 0 : _b.browser) === null || _c === void 0 ? void 0 : _c.name,
(_f = (_e = (_d = this.amplitudeInstance) === null || _d === void 0 ? void 0 : _d._ua) === null || _e === void 0 ? void 0 : _e.browser) === null || _f === void 0 ? void 0 : _f.major,
]
.filter((e) => e !== null && e !== undefined)
.join(' ');
}
getDeviceModel() {
var _a, _b, _c;
return (_c = (_b = (_a = this.amplitudeInstance) === null || _a === void 0 ? void 0 : _a._ua) === null || _b === void 0 ? void 0 : _b.os) === null || _c === void 0 ? void 0 : _c.name;
}
}
/**
* @deprecated Update your version of the amplitude analytics-js SDK to 8.17.0+ and for seamless
* integration with the amplitude analytics SDK.
*/
class AmplitudeAnalyticsProvider {
constructor(amplitudeInstance) {
this.amplitudeInstance = amplitudeInstance;
}
track(event) {
this.amplitudeInstance.logEvent(event.name, event.properties);
}
setUserProperty(event) {
var _a;
// if the variant has a value, set the user property and log an event
this.amplitudeInstance.setUserProperties({
[event.userProperty]: (_a = event.variant) === null || _a === void 0 ? void 0 : _a.value,
});
}
unsetUserProperty(event) {
// if the variant does not have a value, unset the user property
this.amplitudeInstance['_logEvent']('$identify', null, null, {
$unset: { [event.userProperty]: '-' },
});
}
}
function __awaiter(thisArg, _arguments, P, generator) {
function adopt(value) {
return value instanceof P ? value : new P(function (resolve) {
resolve(value);
});
}
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
}
function rejected(value) {
try {
step(generator["throw"](value));
} catch (e) {
reject(e);
}
}
function step(result) {
result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected);
}
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
}
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
};
const parseAmplitudeCookie = (apiKey, newFormat = false) => {
// Get the cookie value
const key = generateKey(apiKey, newFormat);
let value = undefined;
const cookies = safeGlobal.document.cookie.split('; ');
for (const cookie of cookies) {
const [cookieKey, cookieValue] = cookie.split('=', 2);
if (cookieKey === key) {
value = decodeURIComponent(cookieValue);
}
}
if (!value) {
return;
}
// Parse cookie value depending on format
try {
// New format
if (newFormat) {
const decoding = atob(value);
return JSON.parse(decodeURIComponent(decoding));
}
// Old format
const values = value.split('.');
let userId = undefined;
if (values.length >= 2 && values[1]) {
userId = atob(values[1]);
}
return {
deviceId: values[0],
userId,
};
}
catch (e) {
return;
}
};
const parseAmplitudeLocalStorage = (apiKey) => {
const key = generateKey(apiKey, true);
try {
const value = safeGlobal.localStorage.getItem(key);
if (!value)
return;
const state = JSON.parse(value);
if (typeof state !== 'object')
return;
return state;
}
catch (_a) {
return;
}
};
const parseAmplitudeSessionStorage = (apiKey) => {
const key = generateKey(apiKey, true);
try {
const value = safeGlobal.sessionStorage.getItem(key);
if (!value)
return;
const state = JSON.parse(value);
if (typeof state !== 'object')
return;
return state;
}
catch (_a) {
return;
}
};
const generateKey = (apiKey, newFormat) => {
if (newFormat) {
if ((apiKey === null || apiKey === void 0 ? void 0 : apiKey.length) < 10) {
return;
}
return `AMP_${apiKey.substring(0, 10)}`;
}
if ((apiKey === null || apiKey === void 0 ? void 0 : apiKey.length) < 6) {
return;
}
return `amp_${apiKey.substring(0, 6)}`;
};
/**
* Integration plugin for Amplitude Analytics. Uses the analytics connector to
* track events and get user identity.
*
* On initialization, this plugin attempts to read the user identity from all
* the storage locations and formats supported by the analytics SDK, then
* commits the identity to the connector. The order of locations checks are:
* - Cookie
* - Cookie (Legacy)
* - Local Storage
* - Session Storage
*
* Events are tracked only if the connector has an event receiver set, otherwise
* track returns false, and events are persisted and managed by the
* IntegrationManager.
*/
class AmplitudeIntegrationPlugin {
constructor(apiKey, connector, timeoutMillis) {
this.type = 'integration';
this.apiKey = apiKey;
this.identityStore = connector.identityStore;
this.eventBridge = connector.eventBridge;
this.contextProvider = connector.applicationContextProvider;
this.timeoutMillis = timeoutMillis;
this.loadPersistedState();
if (timeoutMillis <= 0) {
this.setup = undefined;
}
}
setup(config, client) {
return __awaiter(this, void 0, void 0, function* () {
// Setup automatic fetch on amplitude identity change.
if (config === null || config === void 0 ? void 0 : config.automaticFetchOnAmplitudeIdentityChange) {
this.identityStore.addIdentityListener(() => {
client === null || client === void 0 ? void 0 : client.fetch();
});
}
return this.waitForConnectorIdentity(this.timeoutMillis);
});
}
getUser() {
const identity = this.identityStore.getIdentity();
return {
user_id: identity.userId,
device_id: identity.deviceId,
user_properties: identity.userProperties,
version: this.contextProvider.versionName,
};
}
track(event) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (!this.eventBridge.receiver) {
return false;
}
this.eventBridge.logEvent({
eventType: event.eventType,
eventProperties: event.eventProperties,
});
return true;
}
loadPersistedState() {
// Avoid reading state if the api key is undefined or an experiment
// deployment.
if (!this.apiKey || this.apiKey.startsWith('client-')) {
return false;
}
// New cookie format
let user = parseAmplitudeCookie(this.apiKey, true);
if (user) {
this.commitIdentityToConnector(user);
return true;
}
// Old cookie format
user = parseAmplitudeCookie(this.apiKey, false);
if (user) {
this.commitIdentityToConnector(user);
return true;
}
// Local storage
user = parseAmplitudeLocalStorage(this.apiKey);
if (user) {
this.commitIdentityToConnector(user);
return true;
}
// Session storage
user = parseAmplitudeSessionStorage(this.apiKey);
if (user) {
this.commitIdentityToConnector(user);
return true;
}
return false;
}
commitIdentityToConnector(user) {
const editor = this.identityStore.editIdentity();
editor.setDeviceId(user.deviceId);
if (user.userId) {
editor.setUserId(user.userId);
}
editor.commit();
}
waitForConnectorIdentity(ms) {
return __awaiter(this, void 0, void 0, function* () {
const identity = this.identityStore.getIdentity();
if (!identity.userId && !identity.deviceId) {
return Promise.race([
new Promise((resolve) => {
const listener = () => {
resolve();
this.identityStore.removeIdentityListener(listener);
};
this.identityStore.addIdentityListener(listener);
}),
new Promise((_, reject) => {
safeGlobal.setTimeout(reject, ms, 'Timed out waiting for Amplitude Analytics SDK to initialize.');
}),
]);
}
});
}
}
function unfetch(e,n){return n=n||{},new Promise(function(t,r){var s=new XMLHttpRequest,o=[],u=[],i={},a=function(){return {ok:2==(s.status/100|0),statusText:s.statusText,status:s.status,url:s.responseURL,text:function(){return Promise.resolve(s.responseText)},json:function(){return Promise.resolve(JSON.parse(s.responseText))},blob:function(){return Promise.resolve(new Blob([s.response]))},clone:a,headers:{keys:function(){return o},entries:function(){return u},get:function(e){return i[e.toLowerCase()]},has:function(e){return e.toLowerCase()in i}}}};for(var l in s.open(n.method||"get",e,!0),s.onload=function(){s.getAllResponseHeaders().replace(/^(.*?):[^\S\n]*([\s\S]*?)$/gm,function(e,n,t){o.push(n=n.toLowerCase()),u.push([n,t]),i[n]=i[n]?i[n]+","+t:t;}),t(a());},s.onerror=r,s.withCredentials="include"==n.credentials,n.headers)s.setRequestHeader(l,n.headers[l]);s.send(n.body||null);})}
/**
* @packageDocumentation
* @internal
*/
const fetch = safeGlobal.fetch || unfetch;
/*
* Copied from:
* https://github.com/github/fetch/issues/175#issuecomment-284787564
*/
const timeout = (promise, timeoutMillis) => {
// Don't timeout if timeout is null or invalid
if (timeoutMillis == null || timeoutMillis <= 0) {
return promise;
}
return new Promise(function (resolve, reject) {
safeGlobal.setTimeout(function () {
reject(new TimeoutError('Request timeout after ' + timeoutMillis + ' milliseconds'));
}, timeoutMillis);
promise.then(resolve, reject);
});
};
const _request = (requestUrl, method, headers, data, timeoutMillis) => {
const call = () => __awaiter(void 0, void 0, void 0, function* () {
const response = yield fetch(requestUrl, {
method: method,
headers: headers,
body: data,
});
const simpleResponse = {
status: response.status,
body: yield response.text(),
};
return simpleResponse;
});
return timeout(call(), timeoutMillis);
};
/**
* Wrap the exposed HttpClient in a CoreClient implementation to work with
* FlagsApi and EvaluationApi.
*/
class WrapperClient {
constructor(client) {
this.client = client;
}
request(request) {
return __awaiter(this, void 0, void 0, function* () {
return yield this.client.request(request.requestUrl, request.method, request.headers, null, request.timeoutMillis);
});
}
}
const FetchHttpClient = { request: _request };
/**
* Log level enumeration for controlling logging verbosity.
* @category Logging
*/
var LogLevel;
(function (LogLevel) {
/**
* Disable all logging
*/
LogLevel[LogLevel["Disable"] = 0] = "Disable";
/**
* Error level logging - only critical errors
*/
LogLevel[LogLevel["Error"] = 1] = "Error";
/**
* Warning level logging - errors and warnings
*/
LogLevel[LogLevel["Warn"] = 2] = "Warn";
/**
* Info level logging - errors, warnings, and informational messages
*/
LogLevel[LogLevel["Info"] = 3] = "Info";
/**
* Debug level logging - errors, warnings, info, and debug messages
*/
LogLevel[LogLevel["Debug"] = 4] = "Debug";
/**
* Verbose level logging - all messages including verbose details
*/
LogLevel[LogLevel["Verbose"] = 5] = "Verbose";
})(LogLevel || (LogLevel = {}));
/**
* Determines the primary source of variants before falling back.
*
* @category Source
*/
var Source;
(function (Source) {
/**
* The default way to source variants within your application. Before the
* assignments are fetched, `getVariant(s)` will fallback to local storage
* first, then `initialVariants` if local storage is empty. This option
* effectively falls back to an assignment fetched previously.
*/
Source["LocalStorage"] = "localStorage";
/**
* This bootstrap option is used primarily for servers-side rendering using an
* Experiment server SDK. This bootstrap option always prefers the config
* `initialVariants` over data in local storage, even if variants are fetched
* successfully and stored locally.
*/
Source["InitialVariants"] = "initialVariants";
})(Source || (Source = {}));
/**
* Indicates from which source the variant() function determines the variant
*
* @category Source
*/
var VariantSource;
(function (VariantSource) {
VariantSource["LocalStorage"] = "storage";
VariantSource["InitialVariants"] = "initial";
VariantSource["SecondaryLocalStorage"] = "secondary-storage";
VariantSource["SecondaryInitialVariants"] = "secondary-initial";
VariantSource["FallbackInline"] = "fallback-inline";
VariantSource["FallbackConfig"] = "fallback-config";
VariantSource["LocalEvaluation"] = "local-evaluation";
})(VariantSource || (VariantSource = {}));
/**
* Returns true if the VariantSource is one of the fallbacks (inline or config)
*
* @param source a {@link VariantSource}
* @returns true if source is {@link VariantSource.FallbackInline} or {@link VariantSource.FallbackConfig}
*/
const isFallback = (source) => {
return (!source ||
source === VariantSource.FallbackInline ||
source === VariantSource.FallbackConfig ||
source === VariantSource.SecondaryInitialVariants);
};
/**
Defaults for Experiment Config options
| **Option** | **Default** |
|------------------|-----------------------------------|
| **debug** | `false` |
| **logLevel** | `LogLevel.Error` |
| **logger** | `null` (ConsoleLogger will be used) |
| **instanceName** | `$default_instance` |
| **fallbackVariant** | `null` |
| **initialVariants** | `null` |
| **initialFlags** | `undefined` |
| **source** | `Source.LocalStorage` |
| **serverUrl** | `"https://api.lab.amplitude.com"` |
| **flagsServerUrl** | `"https://flag.lab.amplitude.com"` |
| **serverZone** | `"US"` |
| **assignmentTimeoutMillis** | `10000` |
| **retryFailedAssignment** | `true` |
| **automaticExposureTracking** | `true` |
| **pollOnStart** | `true` |
| **flagConfigPollingIntervalMillis** | `300000` |
| **fetchOnStart** | `true` |
| **automaticFetchOnAmplitudeIdentityChange** | `false` |
| **userProvider** | `null` |
| **analyticsProvider** | `null` |
| **exposureTrackingProvider** | `null` |
*
* @category Configuration
*/
const Defaults = {
debug: false,
logLevel: LogLevel.Error,
loggerProvider: null,
instanceName: '$default_instance',
fallbackVariant: {},
initialVariants: {},
initialFlags: undefined,
source: Source.LocalStorage,
serverUrl: 'https://api.lab.amplitude.com',
flagsServerUrl: 'https://flag.lab.amplitude.com',
serverZone: 'US',
fetchTimeoutMillis: 10000,
retryFetchOnFailure: true,
throwOnError: false,
automaticExposureTracking: true,
pollOnStart: true,
flagConfigPollingIntervalMillis: 300000,
fetchOnStart: true,
automaticFetchOnAmplitudeIdentityChange: false,
userProvider: null,
analyticsProvider: null,
exposureTrackingProvider: null,
httpClient: FetchHttpClient,
};
var version = "1.20.1";
const MAX_QUEUE_SIZE = 512;
/**
* Handles integration plugin management, event persistence and deduplication.
*/
class IntegrationManager {
constructor(config, client) {
var _a;
this.isReady = new Promise((resolve) => {
this.resolve = resolve;
});
this.config = config;
this.client = client;
const instanceName = (_a = config.instanceName) !== null && _a !== void 0 ? _a : Defaults.instanceName;
this.queue = new PersistentTrackingQueue(instanceName);
this.cache = new SessionDedupeCache(instanceName);
}
/**
* Returns a promise when the integration has completed setup. If no
* integration has been set, returns a resolved promise.
*/
ready() {
if (!this.integration) {
return Promise.resolve();
}
return this.isReady;
}
/**
* Set the integration to be managed. An existing integration is torndown,
* and the new integration is setup. This function resolves the promise
* returned by ready() if it has not already been resolved.
*
* @param integration the integration to manage.
*/
setIntegration(integration) {
if (this.integration && this.integration.teardown) {
void this.integration.teardown();
}
this.integration = integration;
if (integration.setup) {
this.integration.setup(this.config, this.client).then(() => {
this.queue.setTracker(this.integration.track.bind(integration));
this.resolve();
}, () => {
this.queue.setTracker(this.integration.track.bind(integration));
this.resolve();
});
}
else {
this.queue.setTracker(this.integration.track.bind(integration));
this.resolve();
}
}
/**
* Get the user from the integration. If no integration is set, returns an
* empty object.
*/
getUser() {
if (!this.integration) {
return {};
}
return this.integration.getUser();
}
/**
* Deduplicates exposures using session storage, then tracks the event to the
* integration. If no integration is set, or if the integration returns false,
* the event is persisted in local storage.
*
* @param exposure
*/
track(exposure) {
if (this.cache.shouldTrack(exposure)) {
const event = this.getExposureEvent(exposure);
this.queue.push(event);
}
}
getExposureEvent(exposure) {
var _a, _b, _c;
let event = {
eventType: '$exposure',
eventProperties: exposure,
};
if ((_a = exposure.metadata) === null || _a === void 0 ? void 0 : _a.exposureEvent) {
// Metadata specifically passes the exposure event definition
event = {
eventType: (_b = exposure.metadata) === null || _b === void 0 ? void 0 : _b.exposureEvent,
eventProperties: exposure,
};
}
else if (((_c = exposure.metadata) === null || _c === void 0 ? void 0 : _c.deliveryMethod) === 'web') {
// Web experiments track impression events by default
event = {
eventType: '$impression',
eventProperties: exposure,
};
}
return event;
}
}
class SessionDedupeCache {
constructor(instanceName) {
this.isSessionStorageAvailable = checkIsSessionStorageAvailable();
this.inMemoryCache = {};
this.storageKey = `EXP_sent_v2_${instanceName}`;
// Remove previous version of storage if it exists.
if (this.isSessionStorageAvailable) {
safeGlobal.sessionStorage.removeItem(`EXP_sent_${instanceName}`);
}
}
shouldTrack(exposure) {
var _a;
// Always track web impressions.
if (((_a = exposure.metadata) === null || _a === void 0 ? void 0 : _a.deliveryMethod) === 'web') {
return true;
}
this.loadCache();
const cachedExposure = this.inMemoryCache[exposure.flag_key];
let shouldTrack = false;
if (!cachedExposure || cachedExposure.variant !== exposure.variant) {
shouldTrack = true;
this.inMemoryCache[exposure.flag_key] = exposure;
}
this.storeCache();
return shouldTrack;
}
loadCache() {
if (this.isSessionStorageAvailable) {
const storedCache = safeGlobal.sessionStorage.getItem(this.storageKey);
this.inMemoryCache = storedCache ? JSON.parse(storedCache) : {};
}
}
storeCache() {
if (this.isSessionStorageAvailable) {
safeGlobal.sessionStorage.setItem(this.storageKey, JSON.stringify(this.inMemoryCache));
}
}
}
class PersistentTrackingQueue {
constructor(instanceName, maxQueueSize = MAX_QUEUE_SIZE) {
this.isLocalStorageAvailable = isLocalStorageAvailable();
this.inMemoryQueue = [];
this.storageKey = `EXP_unsent_${instanceName}`;
this.maxQueueSize = maxQueueSize;
}
push(event) {
this.loadQueue();
this.inMemoryQueue.push(event);
this.flush();
this.storeQueue();
}
setTracker(tracker) {
this.tracker = tracker;
this.poller = safeGlobal.setInterval(() => {
this.loadFlushStore();
}, 1000);
this.loadFlushStore();
}
flush() {
if (!this.tracker)
return;
if (this.inMemoryQueue.length === 0)
return;
for (const event of this.inMemoryQueue) {
try {
if (!this.tracker(event)) {
return;
}
}
catch (e) {
return;
}
}
this.inMemoryQueue = [];
if (this.poller) {
safeGlobal.clearInterval(this.poller);
this.poller = undefined;
}
}
loadQueue() {
if (this.isLocalStorageAvailable) {
const storedQueue = safeGlobal.localStorage.getItem(this.storageKey);
this.inMemoryQueue = storedQueue ? JSON.parse(storedQueue) : [];
}
}
storeQueue() {
if (this.isLocalStorageAvailable) {
// Trim the queue if it is too large.
if (this.inMemoryQueue.length > this.maxQueueSize) {
this.inMemoryQueue = this.inMemoryQueue.slice(this.inMemoryQueue.length - this.maxQueueSize);
}
safeGlobal.localStorage.setItem(this.storageKey, JSON.stringify(this.inMemoryQueue));
}
}
loadFlushStore() {
this.loadQueue();
this.flush();
this.storeQueue();
}
}
const checkIsSessionStorageAvailable = () => {
const globalScope = getGlobalScope();
if (globalScope) {
try {
const testKey = 'EXP_test';
globalScope.sessionStorage.setItem(testKey, testKey);
globalScope.sessionStorage.removeItem(testKey);
return true;
}
catch (e) {
return false;
}
}
return false;
};
/* eslint-disable @typescript-eslint/no-explicit-any*/
/**
* Internal logger class that wraps a Logger implementation and handles log level filtering.
* This class provides a centralized logging mechanism for the Experiment client.
* @category Logging
*/
class AmpLogger {
/**
* Creates a new AmpLogger instance
* @param logger The underlying logger implementation to use
* @param logLevel The minimum log level to output. Messages below this level will be ignored.
*/
constructor(logger, logLevel = LogLevel.Error) {
this.logger = logger;
this.logLevel = logLevel;
}
/**
* Log an error message
* @param message The message to log
* @param optionalParams Additional parameters to log
*/
error(message, ...optionalParams) {
if (this.logLevel >= LogLevel.Error) {
this.logger.error(message, ...optionalParams);
}
}
/**
* Log a warning message
* @param message The message to log
* @param optionalParams Additional parameters to log
*/
warn(message, ...optionalParams) {
if (this.logLevel >= LogLevel.Warn) {
this.logger.warn(message, ...optionalParams);
}
}
/**
* Log an informational message
* @param message The message to log
* @param optionalParams Additional parameters to log
*/
info(message, ...optionalParams) {
if (this.logLevel >= LogLevel.Info) {
this.logger.info(message, ...optionalParams);
}
}
/**
* Log a debug message
* @param message The message to log
* @param optionalParams Additional parameters to log
*/
debug(message, ...optionalParams) {
if (this.logLevel >= LogLevel.Debug) {
this.logger.debug(message, ...optionalParams);
}
}
/**
* Log a verbose message
* @param message The message to log
* @param optionalParams Additional parameters to log
*/
verbose(message, ...optionalParams) {
if (this.logLevel >= LogLevel.Verbose) {
this.logger.verbose(message, ...optionalParams);
}
}
}
/**
* Default console-based logger implementation.
* This logger uses the browser's console API to output log messages.
* Log level filtering is handled by the AmpLogger wrapper class.
* @category Logging
*/
class ConsoleLogger {
/**
* Log an error message
* @param message The message to log
* @param optionalParams Additional parameters to log
*/
error(message, ...optionalParams) {
console.error(message, ...optionalParams);
}
/**
* Log a warning message
* @param message The message to log
* @param optionalParams Additional parameters to log
*/
warn(message, ...optionalParams) {
console.warn(message, ...optionalParams);
}
/**
* Log an informational message
* @param message The message to log
* @param optionalParams Additional parameters to log
*/
info(message, ...optionalParams) {
console.info(message, ...optionalParams);
}
/**
* Log a debug message
* @param message The message to log
* @param optionalParams Additional parameters to log
*/
debug(message, ...optionalParams) {
console.debug(message, ...optionalParams);
}
/**
* Log a verbose message
* @param message The message to log
* @param optionalParams Additional parameters to log
*/
verbose(message, ...optionalParams) {
console.debug(message, ...optionalParams);
}
}
class LocalStorage {
constructor() {
this.globalScope = getGlobalScope();
}
get(key) {
var _a;
return (_a = this.globalScope) === null || _a === void 0 ? void 0 : _a.localStorage.getItem(key);
}
put(key, value) {
var _a;
(_a = this.globalScope) === null || _a === void 0 ? void 0 : _a.localStorage.setItem(key, value);
}
delete(key) {
var _a;
(_a = this.globalScope) === null || _a === void 0 ? void 0 : _a.localStorage.removeItem(key);
}
}
const getVariantStorage = (deploymentKey, instanceName, storage) => {
const truncatedDeployment = deploymentKey.substring(deploymentKey.length - 6);
const namespace = `amp-exp-${instanceName}-${truncatedDeployment}`;
return new LoadStoreCache(namespace, storage, transformVariantFromStorage);
};
const getFlagStorage = (deploymentKey, instanceName, storage = new LocalStorage()) => {
const truncatedDeployment = deploymentKey.substring(deploymentKey.length - 6);
const namespace = `amp-exp-${instanceName}-${truncatedDeployment}-flags`;
return new LoadStoreCache(namespace, storage);
};
const getVariantsOptionsStorage = (deploymentKey, instanceName, storage = new LocalStorage()) => {
const truncatedDeployment = deploymentKey.substring(deploymentKey.length - 6);
const namespace = `amp-exp-${instanceName}-${truncatedDeployment}-variants-options`;
return new SingleValueStoreCache(namespace, storage);
};
class SingleValueStoreCache {
constructor(namespace, storage) {
this.namespace = namespace;
this.storage = storage;
}
get() {
return this.value;
}
put(value) {
this.value = value;
}
load() {
const value = this.storage.get(this.namespace);
if (value) {
this.value = JSON.parse(value);
}
}
store() {
if (this.value === undefined) {
// Delete the key if the value is undefined
this.storage.delete(this.namespace);
}
else {
// Also store false or null values
this.storage.put(this.namespace, JSON.stringify(this.value));
}
}
}
class LoadStoreCache {
constructor(namespace, storage, transformer) {
this.cache = {};
this.namespace = namespace;
this.storage = storage;
this.transformer = transformer;
}
get(key) {
return this.cache[key];
}
getAll() {
return Object.assign({}, this.cache);
}
put(key, value) {
this.cache[key] = value;
}
putAll(values) {
for (const key of Object.keys(values)) {
this.cache[key] = values[key];
}
}
remove(key) {
delete this.cache[key];
}
clear() {
this.cache = {};
}
load() {
const rawValues = this.storage.get(this.namespace);
let jsonValues;
try {
jsonValues = JSON.parse(rawValues) || {};
}
catch (_a) {
// Do nothing
return;
}
const values = {};
for (const key of Object.keys(jsonValues)) {
try {
let value;
if (this.transformer) {
value = this.transformer(jsonValues[key]);
}
else {
value = jsonValues[key];
}
if (value) {
values[key] = value;
}
}
catch (_b) {
// Do nothing
}
}
this.clear();
this.putAll(values);
}
store(values = this.cache) {
this.storage.put(this.namespace, JSON.stringify(values));
}
}
const transformVariantFromStorage = (storageValue) => {
if (typeof storageValue === 'string') {
// From v0 string format
return {
key: storageValue,
value: storageValue,
};
}
else if (typeof storageValue === 'object') {
// From v1 or v2 object format
const key = storageValue['key'];
const value = storageValue['value'];
const payload = storageValue['payload'];
let metadata = storageValue['metadata'];
let experimentKey = storageValue['expKey'];
if (metadata && metadata.experimentKey) {
experimentKey = metadata.experimentKey;
}
else if (experimentKey) {
metadata = metadata || {};
metadata['experimentKey'] = experimentKey;
}
const variant = {};
if (key) {
variant.key = key;
}
else if (value) {
variant.key = value;
}
if (value)
variant.value = value;
if (metadata)
variant.metadata = metadata;
if (payload)
variant.payload = payload;
if (experimentKey)
variant.expKey = experimentKey;
return variant;
}
};
class SessionStorage {
constructor() {
this.globalScope = getGlobalScope();
}
get(key) {
var _a;
return (_a = this.globalScope) === null || _a === void 0 ? void 0 : _a.sessionStorage.getItem(key);
}
put(key, value) {
var _a;
(_a = this.globalScope) === null || _a === void 0 ? void 0 : _a.sessionStorage.setItem(key, value);
}
delete(key) {
var _a;
(_a = this.globalScope) === null || _a === void 0 ? void 0 : _a.sessionStorage.removeItem(key);
}
}
/**
* Event for tracking a user's exposure to a variant. This event will not count
* towards your analytics event volume.
*
* @deprecated use ExposureTrackingProvider instead
*/
const exposureEvent = (user, key, variant, source) => {
const name = '[Experiment] Exposure';
const value = variant === null || variant === void 0 ? void 0 : variant.value;
const userProperty = `[Experiment] ${key}`;
return {
name,
user,
key,
variant,
userProperty,
properties: {
key,
variant: value,
source,
},
userProperties: {
[userProperty]: value,
},
};
};
const isNullOrUndefined = (value) => {
return value === null || value === undefined;
};
const isNullUndefinedOrEmpty = (value) => {
if (isNullOrUndefined(value))
return true;
return value && Object.keys(value).length === 0;
};
/**
* Filters out null and undefined values from an object, returning a new object
* with only defined values. This is useful for config merging where you want
* defaults to take precedence over explicit null/undefined values.
*/
const filterNullUndefined = (obj) => {
if (!obj || typeof obj !== 'object') {
return {};
}
const filtered = {};
for (const [key, value] of Object.entries(obj)) {
if (!isNullOrUndefined(value)) {
filtered[key] = value;
}
}
return filtered;
};
const isLocalEvaluationMode = (flag) => {
var _a;
return ((_a = flag === null || flag === void 0 ? void 0 : flag.metadata) === null || _a === void 0 ? void 0 : _a.evaluationMode) === 'local';
};
class Backoff {
constructor(attempts, min, max, scalar) {
this.started = false;
this.done = false;
this.attempts = attempts;
this.min = min;
this.max = max;
this.scalar = scalar;
}
start(fn) {
return __awaiter(this, void 0, void 0, function* () {
if (!this.started) {
this.started = true;
}
else {
throw Error('Backoff already started');
}
yield this.backoff(fn, 0, this.min);
});
}
cancel() {
this.done = true;
clearTimeout(this.timeoutHandle);
}
backoff(fn, attempt, delay) {
return __awaiter(this, void 0, void 0, function* () {
if (this.done) {
return;
}
this.timeoutHandle = safeGlobal.setTimeout(() => __awaiter(this, void 0, void 0, function* () {
try {
yield fn();
}
catch (e) {
const nextAttempt = attempt + 1;
if (nextAttempt < this.attempts) {
const nextDelay = Math.min(delay * this.scalar, this.max);
this.backoff(fn, nextAttempt, nextDelay);
}
}
}), delay);
});
}
}
const convertUserToContext = (user) => {
var _a, _b;
if (!user) {
return {};
}
const context = { user: user };
// add page context
const globalScope = getGlobalScope();
if (globalScope) {
context.page = {
url: globalScope.location.href,
};
}
const groups = {};
if (!user.groups) {
return context;
}
for (const groupType of Object.keys(user.groups)) {
const groupNames = user.groups[groupType];
if (groupNames.length > 0 && groupNames[0]) {
const groupName = groupNames[0];
const groupNameMap = {
group_name: groupName,
};
// Check for group properties
const groupProperties = (_b = (_a = user.group_properties) === null || _a === void 0 ? void 0 : _a[groupType]) === null || _b === void 0 ? void 0 : _b[groupName];
if (groupProperties && Object.keys(groupProperties).length > 0) {
groupNameMap['group_properties'] = groupProperties;
}
groups[groupType] = groupNameMap;
}
}
if (Object.keys(groups).length > 0) {
context['groups'] = groups;
}
delete context.user['groups'];
delete context.user['group_properties'];
return context;
};
const convertVariant = (value) => {
if (value === null || value === undefined) {
return {};
}
if (typeof value == 'string') {
return {
key: value,
value: value,
};
}
else {
return value;
}
};
const convertEvaluationVariantToVariant = (evaluationVariant) => {
if (!evaluationVariant) {
return {};
}
let experimentKey = undefined;
if (evaluationVariant.metadata) {
experimentKey = evaluationVariant.metadata['experimentKey'];
}
const variant = {};
if (evaluationVariant.key)
variant.key = evaluationVariant.key;
if (evaluationVariant.value)
variant.value = evaluationVariant.value;
if (evaluationVariant.payload)
variant.payload = evaluationVariant.payload;
if (experimentKey)
variant.expKey = experimentKey;
if (evaluationVariant.metadata)
variant.metadata = evaluationVariant.metadata;
return variant;
};
/**
* A wrapper for an analytics provider which only sends one exposure event per
* flag, per variant, per session. In other words, wrapping an analytics
* provider in this class will prevent the same exposure event to be sent twice
* in one session.
*/
class SessionAnalyticsProvider {
constructor(analyticsProvider) {
// In memory record of flagKey and variant value to in order to only set
// user properties and track an exposure event once per session unless the
// variant value changes
this.setProperties = {};
this.unsetProperties = {};
this.analyticsProvider = analyticsProvider;
}
track(event) {
if (this.setProperties[event.key] == event.variant.value) {
return;
}
else {
this.setProperties[event.key] = event.variant.value;
delete this.unsetProperties[event.key];
}
this.analyticsProvider.track(event);
}
setUserProperty(event) {
if (this.setProperties[event.key] == event.variant.value) {
return;
}
this.analyticsProvider.setUserProperty(event);
}
unsetUserProperty(event) {
if (this.unsetProperties[event.key]) {
return;
}
else {
this.unsetProperties[event.key] = 'unset';
delete this.setProperties[event.key];
}
this.analyticsProvider.unsetUserProperty(event);
}
}
/**
* A wrapper for an exposure tracking provider which only sends one exposure event per
* flag, per variant, per user session. When the user identity (userId or deviceId) changes,
* the tracking cache is reset to ensure exposures are tracked for the new user session.
*/
class UserSessionExposureTracker {
constructor(exposureTrackingProvider) {
this.tracked = {};
this.identity = {};
this.exposureTrackingProvider = exposureTrackingProvider;
}
track(exposure, user) {
const newIdentity = {
userId: user === null || user === void 0 ? void 0 : user.user_id,
deviceId: user === null || user === void 0 ? void 0 : user.device_id,
};
if (!this.identityEquals(this.identity, newIdentity)) {
this.tracked = {};
}
this.identity = newIdentity;
const hasTrackedFlag = exposure.flag_key in this.tracked;
const trackedVariant = this.tracked[exposure.flag_key];
if (hasTrackedFlag && trackedVariant === exposure.variant) {
return;
}
this.tracked[exposure.flag_key] = exposure.variant;
this.exposureTrackingProvider.track(exposure);
}
identityEquals(id1, id2) {
return id1.userId === id2.userId && id1.deviceId === id2.deviceId;
}
}
/**
* @packageDocumentation
* @module experiment-js-client
*/
// Configs which have been removed from the public API.
// May be added back in the future.
const fetchBackoffTimeout = 10000;
const fetchBackoffAttempts = 8;
const fetchBackoffMinMillis = 500;
const fetchBackoffMaxMillis = 10000;
const fetchBackoffScalar = 1.5;
const minFlagPollerIntervalMillis = 60000;
const euServerUrl = 'https://api.lab.eu.amplitude.com';
const euFlagsServerUrl = 'https://flag.lab.eu.amplitude.com';
/**
* The default {@link Client} used to fetch variations from Experiment's
* servers.
*
* @category Core Usage
*/
class ExperimentClient {
/**
* Creates a new ExperimentClient instance.
*
* In most cases you will want to use the `initialize` factory method in
* {@link Experiment}.
*
* @param apiKey The Client key for the Experiment project
* @param config See {@link ExperimentConfig} for config options
*/
constructor(apiKey, config) {
var _a, _b, _c, _d;
this.engine = new EvaluationEngine();
this.isRunning = false;
this.apiKey = apiKey;
// Filter out null/undefined values from config to ensure defaults take precedence
config = filterNullUndefined(config);
// Merge configs with defaults and wrap providers
this.config = Object.assign(Object.assign(Object.assign({}, Defaults), config), {
// Set server URLs separately
serverUrl: (config === null || config === void 0 ? void 0 : config.serverUrl) ||
(((_a = config === null || config === void 0 ? void 0 : config.serverZone) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === 'eu'
? euServerUrl
: Defaults.serverUrl), flagsServerUrl: (config === null || config === void 0 ? void 0 : config.flagsServerUrl) ||
(((_b = config === null || config === void 0 ? void 0 : config.serverZone) === null || _b === void 0 ? void 0 : _b.toLowerCase()) === 'eu'
? euFlagsServerUrl
: Defaults.flagsServerUrl),
// Force minimum flag config polling interval.
flagConfigPollingIntervalMillis: config.flagConfigPollingIntervalMillis < minFlagPollerIntervalMillis
? minFlagPollerIntervalMillis
: (_c = config.flagConfigPollingIntervalMillis) !== null && _c !== void 0 ? _c : Defaults.flagConfigPollingIntervalMillis });
this.logger = new AmpLogger(this.config.loggerProvider || new ConsoleLogger(), ExperimentClient.getLogLevel(config));
const internalInstanceName = (_d = this.config) === null || _d === void 0 ? void 0 : _d['internalInstanceNameSuffix'];
this.isWebExperiment = internalInstanceName === 'web';
this.poller = new Poller(() => this.doFlags(), this.config.flagConfigPollingIntervalMillis);
// Transform initialVariants
if (this.config.initialVariants) {
for (const flagKey in this.config.initialVariants) {
this.config.initialVariants[flagKey] = transformVariantFromStorage(this.config.initialVariants[flagKey]);
}
}
if (this.config.userProvider) {
this.userProvider = this.config.userProvider;
}
if (this.config.analyticsProvider) {
this.analyticsProvider = new SessionAnalyticsProvider(this.config.analyticsProvider);
}
if (this.config.exposureTrackingProvider) {
this.userSessionExposureTracker = new UserSessionExposureTracker(this.config.exposureTrackingProvider);
}
this.integrationManager = new IntegrationManager(this.config, this);
// Setup Remote APIs
const httpClient = new WrapperClient(this.config.httpClient || FetchHttpClient);
this.flagApi = new SdkFlagApi(this.apiKey, this.config.flagsServerUrl, httpClient);
this.evaluationApi = new SdkEvaluationApi(this.apiKey, this.config.serverUrl, httpClient);
// Storage & Caching
let storage;
const storageInstanceName = internalInstanceName
? `${this.config.instanceName}-${internalInstanceName}`
: this.config.instanceName;
if (this.isWebExperiment) {
storage = new SessionStorage();
}
else {
storage = new LocalStorage();
}
this.variants = getVariantStorage(this.apiKey, storageInstanceName, storage);
this.flags = getFlagStorage(this.apiKey, storageInstanceName, storage);
this.fetchVariantsOptions = getVariantsOptionsStorage(this.apiKey, storageInstanceName, storage);
try {
this.flags.load();
this.variants.load();
this.fetchVariantsOptions.load();
}
catch (e) {
// catch localStorage undefined error
}
this.mergeInitialFlagsWithStorage();
}
/**
* Start the SDK by getting flag configurations from the server and fetching
* variants for the user. The promise returned by this function resolves when
* local flag configurations have been updated, and the {@link fetch()}
* result has been received (if the request was made).
*
* To force this function not to fetch variants, set the {@link fetchOnStart}
* configuration option to `false` when initializing the SDK.
*
* Finally, this function will start polling for flag configurations at a
* fixed interval. To disable polling, set the {@link pollOnStart}
* configuration option to `false` on initialization.
*
* @param user The user to set in the SDK.
* @see fetchOnStart
* @see pollOnStart
* @see fetch
* @see variant
*/
start(user) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
if (this.isRunning) {
return;
}
else {
this.isRunning = true;
}
this.setUser(user);
try {
const flagsReadyPromise = this.doFlags();
const fetchOnStart = (_a = this.config.fetchOnStart) !== null && _a !== void 0 ? _a : true;
if (fetchOnStart) {
yield Promise.all([this.fetch(user), flagsReadyPromise]);
}
else {
yield flagsReadyPromise;
}
}
catch (e) {
// If throwOnError is true, rethrow the error
if (this.config.throwOnError) {
throw e;
}
// Otherwise, silently handle the error (existing behavior)
}
if (this.config.pollOnStart) {
this.poller.start();
}
});
}
/**
* Stop the local flag configuration poller.
*/
stop() {
if (!this.isRunning) {
return;
}
this.poller.stop();
this.isRunning = false;
}
/**
* Assign the given user to the SDK and asynchronously fetch all variants
* from the server. Subsequent calls may omit the user from the argument to
* use the user from the previous call.
*
* If an {@link ExperimentUserProvider} has been set, the argument user will
* be