UNPKG

@amplitude/experiment-js-client

Version:
1,425 lines (1,400 loc) 85.5 kB
/* @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