From 1b469f169f2f72674dcb7ae1b344a968e8e81a70 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Tue, 17 Sep 2024 09:32:51 -0700 Subject: [PATCH 01/24] feat: add integration plugin --- .../src/experimentClient.ts | 35 +- packages/experiment-browser/src/factory.ts | 33 +- packages/experiment-browser/src/index.ts | 2 +- .../src/integration/amplitude.ts | 193 ++++++---- .../src/integration/connector.ts | 73 ---- .../src/integration/manager.ts | 175 +++++++++ .../src/providers/amplitude.ts | 102 +++++ .../src/{integration => providers}/default.ts | 29 +- .../experiment-browser/src/types/plugin.ts | 26 ++ packages/experiment-browser/src/types/user.ts | 8 + packages/experiment-browser/src/util/state.ts | 91 +++++ .../experiment-browser/test/client.test.ts | 117 +++++- .../test/defaultUserProvider.test.ts | 104 ++--- .../test/integration/amplitude.test.ts | 257 +++++++++++++ .../test/integration/manager.test.ts | 358 ++++++++++++++++++ packages/experiment-browser/test/util/misc.ts | 8 + .../test/util/state.test.ts | 156 ++++++++ 17 files changed, 1479 insertions(+), 288 deletions(-) delete mode 100644 packages/experiment-browser/src/integration/connector.ts create mode 100644 packages/experiment-browser/src/integration/manager.ts create mode 100644 packages/experiment-browser/src/providers/amplitude.ts rename packages/experiment-browser/src/{integration => providers}/default.ts (86%) create mode 100644 packages/experiment-browser/src/types/plugin.ts create mode 100644 packages/experiment-browser/src/util/state.ts create mode 100644 packages/experiment-browser/test/integration/amplitude.test.ts create mode 100644 packages/experiment-browser/test/integration/manager.test.ts create mode 100644 packages/experiment-browser/test/util/misc.ts create mode 100644 packages/experiment-browser/test/util/state.test.ts diff --git a/packages/experiment-browser/src/experimentClient.ts b/packages/experiment-browser/src/experimentClient.ts index a1f9e711..3ae6887b 100644 --- a/packages/experiment-browser/src/experimentClient.ts +++ b/packages/experiment-browser/src/experimentClient.ts @@ -18,8 +18,7 @@ import { import { version as PACKAGE_VERSION } from '../package.json'; import { Defaults, ExperimentConfig } from './config'; -import { ConnectorUserProvider } from './integration/connector'; -import { DefaultUserProvider } from './integration/default'; +import { IntegrationManager } from './integration/manager'; import { getFlagStorage, getVariantStorage, @@ -31,6 +30,7 @@ import { FetchHttpClient, WrapperClient } from './transport/http'; import { exposureEvent } from './types/analytics'; import { Client, FetchOptions } from './types/client'; import { Exposure, ExposureTrackingProvider } from './types/exposure'; +import { ExperimentPlugin, IntegrationPlugin } from './types/plugin'; import { ExperimentUserProvider } from './types/provider'; import { isFallback, Source, VariantSource } from './types/source'; import { ExperimentUser } from './types/user'; @@ -84,6 +84,7 @@ export class ExperimentClient implements Client { flagPollerIntervalMillis, ); private isRunning = false; + private readonly integrationManager: IntegrationManager; // Deprecated private analyticsProvider: SessionAnalyticsProvider | undefined; @@ -136,6 +137,7 @@ export class ExperimentClient implements Client { this.config.exposureTrackingProvider, ); } + this.integrationManager = new IntegrationManager(this.config, this); // Setup Remote APIs const httpClient = new WrapperClient( this.config.httpClient || FetchHttpClient, @@ -677,7 +679,7 @@ export class ExperimentClient implements Client { timeoutMillis: number, options?: FetchOptions, ): Promise { - user = await this.addContextOrWait(user, 10000); + user = await this.addContextOrWait(user); user = this.cleanUserPropsForFetch(user); this.debug('[Experiment] Fetch variants for user: ', user); const results = await this.evaluationApi.getVariants(user, { @@ -756,13 +758,16 @@ export class ExperimentClient implements Client { private addContext(user: ExperimentUser): ExperimentUser { const providedUser = this.userProvider?.getUser(); + const integrationUser = this.integrationManager.getUser(); const mergedUserProperties = { - ...user?.user_properties, ...providedUser?.user_properties, + ...integrationUser.user_properties, + ...user?.user_properties, }; return { library: `experiment-js-client/${PACKAGE_VERSION}`, - ...this.userProvider?.getUser(), + ...providedUser, + ...integrationUser, ...user, user_properties: mergedUserProperties, }; @@ -770,14 +775,8 @@ export class ExperimentClient implements Client { private async addContextOrWait( user: ExperimentUser, - ms: number, ): Promise { - if (this.userProvider instanceof DefaultUserProvider) { - if (this.userProvider.userProvider instanceof ConnectorUserProvider) { - await this.userProvider.userProvider.identityReady(ms); - } - } - + await this.integrationManager.ready(); return this.addContext(user); } @@ -823,6 +822,7 @@ export class ExperimentClient implements Client { } if (metadata) exposure.metadata = metadata; this.exposureTrackingProvider?.track(exposure); + this.integrationManager.track(exposure); } private legacyExposureInternal( @@ -855,6 +855,17 @@ export class ExperimentClient implements Client { } return true; } + + /** + * Private for now. Should only be used by web experiment. + * @param plugin + * @private + */ + public addPlugin(plugin: ExperimentPlugin): void { + if (plugin.type === 'integration') { + this.integrationManager.setIntegration(plugin as IntegrationPlugin); + } + } } type SourceVariant = { diff --git a/packages/experiment-browser/src/factory.ts b/packages/experiment-browser/src/factory.ts index 6580dafd..01ed41a9 100644 --- a/packages/experiment-browser/src/factory.ts +++ b/packages/experiment-browser/src/factory.ts @@ -2,11 +2,8 @@ import { AnalyticsConnector } from '@amplitude/analytics-connector'; import { Defaults, ExperimentConfig } from './config'; import { ExperimentClient } from './experimentClient'; -import { - ConnectorExposureTrackingProvider, - ConnectorUserProvider, -} from './integration/connector'; -import { DefaultUserProvider } from './integration/default'; +import { DefaultUserProvider } from './providers/default'; +import { AmplitudeIntegrationPlugin } from './integration/amplitude'; const instances = {}; @@ -25,15 +22,10 @@ const initialize = ( // initializing multiple default instances for different api keys. const instanceName = config?.instanceName || Defaults.instanceName; const instanceKey = `${instanceName}.${apiKey}`; - const connector = AnalyticsConnector.getInstance(instanceName); if (!instances[instanceKey]) { config = { ...config, - userProvider: new DefaultUserProvider( - connector.applicationContextProvider, - config?.userProvider, - apiKey, - ), + userProvider: new DefaultUserProvider(config?.userProvider, apiKey), }; instances[instanceKey] = new ExperimentClient(apiKey, config); } @@ -63,17 +55,20 @@ const initializeWithAmplitudeAnalytics = ( if (!instances[instanceKey]) { connector.eventBridge.setInstanceName(instanceName); config = { - userProvider: new DefaultUserProvider( - connector.applicationContextProvider, - new ConnectorUserProvider(connector.identityStore), + userProvider: new DefaultUserProvider(undefined, apiKey), + ...config, + }; + const client = new ExperimentClient(apiKey, config); + client.addPlugin( + new AmplitudeIntegrationPlugin( apiKey, - ), - exposureTrackingProvider: new ConnectorExposureTrackingProvider( + connector.identityStore, connector.eventBridge, + connector.applicationContextProvider, + 10000, ), - ...config, - }; - instances[instanceKey] = new ExperimentClient(apiKey, config); + ); + instances[instanceKey] = client; if (config.automaticFetchOnAmplitudeIdentityChange) { connector.identityStore.addIdentityListener(() => { instances[instanceKey].fetch(); diff --git a/packages/experiment-browser/src/index.ts b/packages/experiment-browser/src/index.ts index 81013c89..74cab0f5 100644 --- a/packages/experiment-browser/src/index.ts +++ b/packages/experiment-browser/src/index.ts @@ -9,7 +9,7 @@ export { ExperimentConfig } from './config'; export { AmplitudeUserProvider, AmplitudeAnalyticsProvider, -} from './integration/amplitude'; +} from './providers/amplitude'; export { Experiment } from './factory'; export { StubExperimentClient } from './stubClient'; export { ExperimentClient } from './experimentClient'; diff --git a/packages/experiment-browser/src/integration/amplitude.ts b/packages/experiment-browser/src/integration/amplitude.ts index bca26ec6..8e07aed7 100644 --- a/packages/experiment-browser/src/integration/amplitude.ts +++ b/packages/experiment-browser/src/integration/amplitude.ts @@ -1,102 +1,131 @@ import { - ExperimentAnalyticsEvent, - ExperimentAnalyticsProvider, -} from '../types/analytics'; -import { ExperimentUserProvider } from '../types/provider'; -import { ExperimentUser } from '../types/user'; + ApplicationContextProvider, + EventBridge, + IdentityStore, +} from '@amplitude/analytics-connector'; +import { safeGlobal } from '@amplitude/experiment-core'; -type AmplitudeIdentify = { - set(property: string, value: unknown): void; - unset(property: string): void; -}; - -type AmplitudeInstance = { - options?: AmplitudeOptions; - _ua?: AmplitudeUAParser; - logEvent(eventName: string, properties: Record): void; - setUserProperties(userProperties: Record): void; - identify(identify: AmplitudeIdentify): void; -}; +import { ExperimentEvent, IntegrationPlugin } from '../types/plugin'; +import { ExperimentUser, UserProperties } from '../types/user'; +import { + AmplitudeState, + parseAmplitudeCookie, + parseAmplitudeLocalStorage, + parseAmplitudeSessionStorage, +} from '../util/state'; -type AmplitudeOptions = { - deviceId?: string; - userId?: string; - versionName?: string; - language?: string; - platform?: string; -}; +export class AmplitudeIntegrationPlugin implements IntegrationPlugin { + type: 'integration'; + private readonly apiKey: string | undefined; + private readonly identityStore: IdentityStore; + private readonly eventBridge: EventBridge; + private readonly contextProvider: ApplicationContextProvider; + private readonly timeoutMillis: number; -type AmplitudeUAParser = { - browser?: { - name?: string; - major?: string; - }; - os?: { - name?: string; - }; -}; + setup: (() => Promise) | undefined = undefined; -/** - * @deprecated Update your version of the amplitude analytics-js SDK to 8.17.0+ and for seamless - * integration with the amplitude analytics SDK. - */ -export class AmplitudeUserProvider implements ExperimentUserProvider { - private amplitudeInstance: AmplitudeInstance; - constructor(amplitudeInstance: AmplitudeInstance) { - this.amplitudeInstance = amplitudeInstance; + constructor( + apiKey: string | undefined, + identityStore: IdentityStore, + eventBridge: EventBridge, + contextProvider: ApplicationContextProvider, + timeoutMillis: number, + ) { + this.apiKey = apiKey; + this.identityStore = identityStore; + this.eventBridge = eventBridge; + this.contextProvider = contextProvider; + this.timeoutMillis = timeoutMillis; + const userLoaded = this.loadPersistedState(); + if (!userLoaded) { + this.setup = async (): Promise => { + return this.waitForConnectorIdentity(this.timeoutMillis); + }; + } } getUser(): ExperimentUser { + const identity = this.identityStore.getIdentity(); return { - device_id: this.amplitudeInstance?.options?.deviceId, - user_id: this.amplitudeInstance?.options?.userId, - version: this.amplitudeInstance?.options?.versionName, - language: this.amplitudeInstance?.options?.language, - platform: this.amplitudeInstance?.options?.platform, - os: this.getOs(), - device_model: this.getDeviceModel(), + user_id: identity.userId, + device_id: identity.deviceId, + user_properties: identity.userProperties as UserProperties, + version: this.contextProvider.versionName, }; } - private getOs(): string { - return [ - this.amplitudeInstance?._ua?.browser?.name, - this.amplitudeInstance?._ua?.browser?.major, - ] - .filter((e) => e !== null && e !== undefined) - .join(' '); - } - - private getDeviceModel(): string { - return this.amplitudeInstance?._ua?.os?.name; - } -} - -/** - * @deprecated Update your version of the amplitude analytics-js SDK to 8.17.0+ and for seamless - * integration with the amplitude analytics SDK. - */ -export class AmplitudeAnalyticsProvider implements ExperimentAnalyticsProvider { - private readonly amplitudeInstance: AmplitudeInstance; - constructor(amplitudeInstance: AmplitudeInstance) { - this.amplitudeInstance = amplitudeInstance; + track(event: ExperimentEvent): boolean { + // 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; } - track(event: ExperimentAnalyticsEvent): void { - this.amplitudeInstance.logEvent(event.name, event.properties); + private loadPersistedState(): boolean { + if (!this.apiKey) { + 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; } - setUserProperty(event: ExperimentAnalyticsEvent): void { - // if the variant has a value, set the user property and log an event - this.amplitudeInstance.setUserProperties({ - [event.userProperty]: event.variant?.value, - }); + private commitIdentityToConnector(user: AmplitudeState) { + const editor = this.identityStore.editIdentity(); + editor.setDeviceId(user.deviceId); + if (user.userId) { + editor.setUserId(user.userId); + } + editor.commit(); } - unsetUserProperty(event: ExperimentAnalyticsEvent): void { - // if the variant does not have a value, unset the user property - this.amplitudeInstance['_logEvent']('$identify', null, null, { - $unset: { [event.userProperty]: '-' }, - }); + private async waitForConnectorIdentity(ms: number): Promise { + 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.', + ); + }), + ]); + } } } diff --git a/packages/experiment-browser/src/integration/connector.ts b/packages/experiment-browser/src/integration/connector.ts deleted file mode 100644 index 1ac289bb..00000000 --- a/packages/experiment-browser/src/integration/connector.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { EventBridge, IdentityStore } from '@amplitude/analytics-connector'; -import { safeGlobal } from '@amplitude/experiment-core'; - -import { Exposure, ExposureTrackingProvider } from '../types/exposure'; -import { ExperimentUserProvider } from '../types/provider'; -import { ExperimentUser } from '../types/user'; - -type UserProperties = Record< - string, - string | number | boolean | Array ->; - -export class ConnectorUserProvider implements ExperimentUserProvider { - private readonly identityStore: IdentityStore; - constructor(identityStore: IdentityStore) { - this.identityStore = identityStore; - } - - async identityReady(ms: number): Promise { - const identity = this.identityStore.getIdentity(); - if (!identity.userId && !identity.deviceId) { - return Promise.race([ - new Promise((resolve) => { - const listener = () => { - resolve(undefined); - this.identityStore.removeIdentityListener(listener); - }; - this.identityStore.addIdentityListener(listener); - }), - new Promise((resolve, reject) => { - safeGlobal.setTimeout( - reject, - ms, - 'Timed out waiting for Amplitude Analytics SDK to initialize. ' + - 'You must ensure that the analytics SDK is initialized prior to calling fetch().', - ); - }), - ]); - } - } - - getUser(): ExperimentUser { - const identity = this.identityStore.getIdentity(); - let userProperties: UserProperties = undefined; - try { - userProperties = identity.userProperties as UserProperties; - } catch { - console.warn('[Experiment] failed to cast user properties'); - } - return { - user_id: identity.userId, - device_id: identity.deviceId, - user_properties: userProperties, - }; - } -} - -export class ConnectorExposureTrackingProvider - implements ExposureTrackingProvider -{ - private readonly eventBridge: EventBridge; - - constructor(eventBridge: EventBridge) { - this.eventBridge = eventBridge; - } - - track(exposure: Exposure): void { - this.eventBridge.logEvent({ - eventType: '$exposure', - eventProperties: exposure, - }); - } -} diff --git a/packages/experiment-browser/src/integration/manager.ts b/packages/experiment-browser/src/integration/manager.ts new file mode 100644 index 00000000..c078d1aa --- /dev/null +++ b/packages/experiment-browser/src/integration/manager.ts @@ -0,0 +1,175 @@ +import { + getGlobalScope, + isLocalStorageAvailable, + safeGlobal, +} from '@amplitude/experiment-core'; + +import { Defaults, ExperimentConfig } from '../config'; +import { Client } from '../types/client'; +import { Exposure } from '../types/exposure'; +import { ExperimentEvent, IntegrationPlugin } from '../types/plugin'; +import { ExperimentUser } from '../types/user'; + +export class IntegrationManager { + private readonly config: ExperimentConfig; + private readonly client: Client; + private integration: IntegrationPlugin; + private readonly queue: PersistentTrackingQueue; + private readonly cache: SessionDedupeCache; + + private resolve: () => void; + private readonly isReady: Promise = new Promise((resolve) => { + this.resolve = resolve; + }); + + constructor(config: ExperimentConfig, client: Client) { + this.config = config; + this.client = client; + const instanceName = config.instanceName ?? Defaults.instanceName; + this.queue = new PersistentTrackingQueue(instanceName); + this.cache = new SessionDedupeCache(instanceName); + } + + ready(): Promise { + if (!this.integration) { + return Promise.resolve(); + } + return this.isReady; + } + + setIntegration(integration: IntegrationPlugin): void { + if (this.integration) { + void this.integration.teardown(); + } + this.integration = integration; + if (integration.setup) { + this.integration.setup(this.config, this.client).then( + () => { + this.queue.tracker = this.integration.track; + this.resolve(); + }, + (e) => { + console.error('Integration setup failed.', e); + this.queue.tracker = this.integration.track; + this.resolve(); + }, + ); + } else { + this.queue.tracker = this.integration.track; + this.resolve(); + } + } + + getUser(): ExperimentUser { + if (!this.integration) { + return {}; + } + return this.integration.getUser(); + } + + track(exposure: Exposure): void { + if (this.cache.shouldTrack(exposure)) { + this.queue.push({ + eventType: '$exposure', + eventProperties: exposure, + }); + } + } +} + +export class SessionDedupeCache { + private readonly storageKey: string; + private readonly isSessionStorageAvailable = isSessionStorageAvailable(); + private inMemoryCache: Record = {}; + + constructor(instanceName: string) { + this.storageKey = `EXP_sent_${instanceName}`; + } + + shouldTrack(exposure: Exposure): boolean { + this.loadCache(); + const value = this.inMemoryCache[exposure.flag_key]; + let shouldTrack = false; + if (!value) { + shouldTrack = true; + this.inMemoryCache[exposure.flag_key] = exposure.variant; + } + this.storeCache(); + return shouldTrack; + } + + private loadCache(): void { + if (this.isSessionStorageAvailable) { + const storedCache = safeGlobal.sessionStorage.getItem(this.storageKey); + this.inMemoryCache = storedCache ? JSON.parse(storedCache) : {}; + } + } + + private storeCache(): void { + if (this.isSessionStorageAvailable) { + safeGlobal.sessionStorage.setItem( + this.storageKey, + JSON.stringify(this.inMemoryCache), + ); + } + } +} + +export class PersistentTrackingQueue { + private readonly storageKey: string; + private readonly isLocalStorageAvailable = isLocalStorageAvailable(); + private inMemoryQueue: ExperimentEvent[] = []; + + tracker: ((event: ExperimentEvent) => boolean) | undefined; + + constructor(instanceName: string) { + this.storageKey = `EXP_unsent_${instanceName}`; + } + + push(event: ExperimentEvent): void { + this.loadQueue(); + this.inMemoryQueue.push(event); + this.flush(); + this.storeQueue(); + } + + private flush(): void { + if (!this.tracker) return; + if (this.inMemoryQueue.length === 0) return; + for (const event of this.inMemoryQueue) { + if (!this.tracker(event)) return; + } + this.inMemoryQueue = []; + } + + private loadQueue(): void { + if (this.isLocalStorageAvailable) { + const storedQueue = safeGlobal.localStorage.getItem(this.storageKey); + this.inMemoryQueue = storedQueue ? JSON.parse(storedQueue) : []; + } + } + + private storeQueue(): void { + if (this.isLocalStorageAvailable) { + safeGlobal.localStorage.setItem( + this.storageKey, + JSON.stringify(this.inMemoryQueue), + ); + } + } +} + +const isSessionStorageAvailable = (): boolean => { + 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; +}; diff --git a/packages/experiment-browser/src/providers/amplitude.ts b/packages/experiment-browser/src/providers/amplitude.ts new file mode 100644 index 00000000..bca26ec6 --- /dev/null +++ b/packages/experiment-browser/src/providers/amplitude.ts @@ -0,0 +1,102 @@ +import { + ExperimentAnalyticsEvent, + ExperimentAnalyticsProvider, +} from '../types/analytics'; +import { ExperimentUserProvider } from '../types/provider'; +import { ExperimentUser } from '../types/user'; + +type AmplitudeIdentify = { + set(property: string, value: unknown): void; + unset(property: string): void; +}; + +type AmplitudeInstance = { + options?: AmplitudeOptions; + _ua?: AmplitudeUAParser; + logEvent(eventName: string, properties: Record): void; + setUserProperties(userProperties: Record): void; + identify(identify: AmplitudeIdentify): void; +}; + +type AmplitudeOptions = { + deviceId?: string; + userId?: string; + versionName?: string; + language?: string; + platform?: string; +}; + +type AmplitudeUAParser = { + browser?: { + name?: string; + major?: string; + }; + os?: { + name?: string; + }; +}; + +/** + * @deprecated Update your version of the amplitude analytics-js SDK to 8.17.0+ and for seamless + * integration with the amplitude analytics SDK. + */ +export class AmplitudeUserProvider implements ExperimentUserProvider { + private amplitudeInstance: AmplitudeInstance; + constructor(amplitudeInstance: AmplitudeInstance) { + this.amplitudeInstance = amplitudeInstance; + } + + getUser(): ExperimentUser { + return { + device_id: this.amplitudeInstance?.options?.deviceId, + user_id: this.amplitudeInstance?.options?.userId, + version: this.amplitudeInstance?.options?.versionName, + language: this.amplitudeInstance?.options?.language, + platform: this.amplitudeInstance?.options?.platform, + os: this.getOs(), + device_model: this.getDeviceModel(), + }; + } + + private getOs(): string { + return [ + this.amplitudeInstance?._ua?.browser?.name, + this.amplitudeInstance?._ua?.browser?.major, + ] + .filter((e) => e !== null && e !== undefined) + .join(' '); + } + + private getDeviceModel(): string { + return this.amplitudeInstance?._ua?.os?.name; + } +} + +/** + * @deprecated Update your version of the amplitude analytics-js SDK to 8.17.0+ and for seamless + * integration with the amplitude analytics SDK. + */ +export class AmplitudeAnalyticsProvider implements ExperimentAnalyticsProvider { + private readonly amplitudeInstance: AmplitudeInstance; + constructor(amplitudeInstance: AmplitudeInstance) { + this.amplitudeInstance = amplitudeInstance; + } + + track(event: ExperimentAnalyticsEvent): void { + this.amplitudeInstance.logEvent(event.name, event.properties); + } + + setUserProperty(event: ExperimentAnalyticsEvent): void { + // if the variant has a value, set the user property and log an event + this.amplitudeInstance.setUserProperties({ + [event.userProperty]: event.variant?.value, + }); + } + + unsetUserProperty(event: ExperimentAnalyticsEvent): void { + // if the variant does not have a value, unset the user property + this.amplitudeInstance['_logEvent']('$identify', null, null, { + $unset: { [event.userProperty]: '-' }, + }); + } +} diff --git a/packages/experiment-browser/src/integration/default.ts b/packages/experiment-browser/src/providers/default.ts similarity index 86% rename from packages/experiment-browser/src/integration/default.ts rename to packages/experiment-browser/src/providers/default.ts index 232533a2..25fb3344 100644 --- a/packages/experiment-browser/src/integration/default.ts +++ b/packages/experiment-browser/src/providers/default.ts @@ -1,4 +1,3 @@ -import { ApplicationContextProvider } from '@amplitude/analytics-connector'; import { getGlobalScope } from '@amplitude/experiment-core'; import { UAParser } from '@amplitude/ua-parser-js'; @@ -18,15 +17,10 @@ export class DefaultUserProvider implements ExperimentUserProvider { private readonly sessionStorage = new SessionStorage(); private readonly storageKey: string; - private readonly contextProvider: ApplicationContextProvider; public readonly userProvider: ExperimentUserProvider | undefined; private readonly apiKey?: string; - constructor( - applicationContextProvider: ApplicationContextProvider, - userProvider?: ExperimentUserProvider, - apiKey?: string, - ) { - this.contextProvider = applicationContextProvider; + + constructor(userProvider?: ExperimentUserProvider, apiKey?: string) { this.userProvider = userProvider; this.apiKey = apiKey; this.storageKey = `EXP_${this.apiKey?.slice(0, 10)}_DEFAULT_USER_PROVIDER`; @@ -34,13 +28,11 @@ export class DefaultUserProvider implements ExperimentUserProvider { getUser(): ExperimentUser { const user = this.userProvider?.getUser() || {}; - const context = this.contextProvider.getApplicationContext(); return { - version: context.versionName, - language: context.language, - platform: context.platform, - os: context.os || this.getOs(this.ua), - device_model: context.deviceModel || this.getDeviceModel(this.ua), + language: this.getLanguage(), + platform: 'Web', + os: this.getOs(this.ua), + device_model: this.getDeviceModel(this.ua), device_category: this.ua.device?.type ?? 'desktop', referring_url: this.globalScope?.document?.referrer.replace(/\/$/, ''), cookie: this.getCookie(), @@ -52,6 +44,15 @@ export class DefaultUserProvider implements ExperimentUserProvider { }; } + private getLanguage(): string { + return ( + (typeof navigator !== 'undefined' && + ((navigator.languages && navigator.languages[0]) || + navigator.language)) || + '' + ); + } + private getOs(ua: UAParser): string { return [ua.browser?.name, ua.browser?.major] .filter((e) => e !== null && e !== undefined) diff --git a/packages/experiment-browser/src/types/plugin.ts b/packages/experiment-browser/src/types/plugin.ts new file mode 100644 index 00000000..36475f78 --- /dev/null +++ b/packages/experiment-browser/src/types/plugin.ts @@ -0,0 +1,26 @@ +import { ExperimentConfig } from '../config'; + +import { Client } from './client'; +import { ExperimentUser } from './user'; + +type PluginTypeIntegration = 'integration'; + +export type ExperimentPluginType = PluginTypeIntegration; + +export interface ExperimentPlugin { + name?: string; + type?: ExperimentPluginType; + setup?(config: ExperimentConfig, client: Client): Promise; + teardown?(): Promise; +} + +export type ExperimentEvent = { + eventType: string; + eventProperties?: Record; +}; + +export interface IntegrationPlugin extends ExperimentPlugin { + type: PluginTypeIntegration; + getUser(): ExperimentUser; + track(event: ExperimentEvent): boolean; +} diff --git a/packages/experiment-browser/src/types/user.ts b/packages/experiment-browser/src/types/user.ts index 8f28b125..7e208724 100644 --- a/packages/experiment-browser/src/types/user.ts +++ b/packages/experiment-browser/src/types/user.ts @@ -148,3 +148,11 @@ export type ExperimentUser = { }; }; }; + +export type UserProperties = { + [propertyName: string]: + | string + | number + | boolean + | Array; +}; diff --git a/packages/experiment-browser/src/util/state.ts b/packages/experiment-browser/src/util/state.ts new file mode 100644 index 00000000..c7db54c6 --- /dev/null +++ b/packages/experiment-browser/src/util/state.ts @@ -0,0 +1,91 @@ +import { safeGlobal } from '@amplitude/experiment-core'; + +export type AmplitudeState = { + deviceId?: string; + userId?: string; +}; + +export const parseAmplitudeCookie = ( + apiKey: string, + newFormat = false, +): AmplitudeState | undefined => { + // Get the cookie value + const key = generateKey(apiKey, newFormat); + let value: string | undefined = undefined; + const cookies = safeGlobal.document.cookie.split('; '); + for (const cookie of cookies) { + const [cookieKey, cookieValue] = cookie.split('='); + if (cookieKey === key) { + value = decodeURIComponent(cookieValue); + } + } + if (!value) { + return; + } + // Parse cookie value depending on format + try { + // New format + if (newFormat) { + const decoding = Buffer.from(value, 'base64').toString('utf-8'); + return JSON.parse(decodeURIComponent(decoding)) as AmplitudeState; + } + // Old format + const values = value.split('.'); + let userId = undefined; + if (values.length >= 2 && values[1]) { + userId = Buffer.from(values[1], 'base64').toString('utf-8'); + } + return { + deviceId: values[0], + userId, + }; + } catch (e) { + return; + } +}; + +export const parseAmplitudeLocalStorage = ( + apiKey: string, +): AmplitudeState | undefined => { + 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 as AmplitudeState; + } catch { + return; + } +}; + +export const parseAmplitudeSessionStorage = ( + apiKey: string, +): AmplitudeState | undefined => { + 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 as AmplitudeState; + } catch { + return; + } +}; + +const generateKey = ( + apiKey: string, + newFormat: boolean, +): string | undefined => { + if (newFormat) { + if (apiKey?.length < 10) { + return; + } + return `AMP_${apiKey.substring(0, 10)}`; + } + if (apiKey?.length < 6) { + return; + } + return `amp_${apiKey.substring(0, 6)}`; +}; diff --git a/packages/experiment-browser/test/client.test.ts b/packages/experiment-browser/test/client.test.ts index eed94563..c8f537b4 100644 --- a/packages/experiment-browser/test/client.test.ts +++ b/packages/experiment-browser/test/client.test.ts @@ -1,5 +1,5 @@ import { AnalyticsConnector } from '@amplitude/analytics-connector'; -import { FetchError } from '@amplitude/experiment-core'; +import { FetchError, safeGlobal } from '@amplitude/experiment-core'; import { ExperimentAnalyticsProvider, @@ -13,13 +13,14 @@ import { Variant, Variants, } from '../src'; -import { ConnectorExposureTrackingProvider } from '../src/integration/connector'; import { HttpClient, SimpleResponse } from '../src/types/transport'; import { randomString } from '../src/util/randomstring'; +import { version as PACKAGE_VERSION } from '../package.json'; import { mockClientStorage } from './util/mock'; +import { ExperimentEvent, IntegrationPlugin } from 'src/types/plugin'; -const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)); +const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)); class TestUserProvider implements ExperimentUserProvider { getUser(): ExperimentUser { @@ -272,9 +273,7 @@ class TestAnalyticsProvider test('ExperimentClient.variant, with exposure tracking provider, track called once per key', async () => { const eventBridge = AnalyticsConnector.getInstance('1').eventBridge; - const exposureTrackingProvider = new ConnectorExposureTrackingProvider( - eventBridge, - ); + const exposureTrackingProvider = new TestExposureTrackingProvider(); const trackSpy = jest.spyOn(exposureTrackingProvider, 'track'); const logEventSpy = jest.spyOn(eventBridge, 'logEvent'); const client = new ExperimentClient(API_KEY, { @@ -298,14 +297,6 @@ test('ExperimentClient.variant, with exposure tracking provider, track called on flag_key: serverKey, variant: serverVariant.value, }); - expect(logEventSpy).toBeCalledTimes(1); - expect(logEventSpy).toHaveBeenCalledWith({ - eventType: '$exposure', - eventProperties: { - flag_key: serverKey, - variant: serverVariant.value, - }, - }); }); /** @@ -1091,6 +1082,9 @@ describe('fetch retry with different response codes', () => { beforeEach(() => { jest.clearAllMocks(); }); + afterEach(() => { + jest.restoreAllMocks(); + }); test.each([ [300, 'Fetch Exception 300', 1], [400, 'Fetch Exception 400', 0], @@ -1161,3 +1155,98 @@ test('test bootstrapping with v2 variants', async () => { client.exposure('test-v2'); expect(exposureObject.experiment_key).toEqual('exp-2'); }); + +describe('integration plugin', () => { + beforeEach(() => { + safeGlobal.sessionStorage.clear(); + safeGlobal.localStorage.clear(); + }); + test('no plugin, with user provider', async () => { + const client = new ExperimentClient(API_KEY, { + userProvider: { + getUser: () => { + return { + user_id: 'user', + device_id: 'device', + user_properties: { k: 'v' }, + }; + }, + }, + }); + mockClientStorage(client); + const user = await client['addContextOrWait'](client.getUser()); + expect(user).toEqual({ + user_id: 'user', + device_id: 'device', + library: `experiment-js-client/${PACKAGE_VERSION}`, + user_properties: { k: 'v' }, + }); + }); + test('no plugin, with exposure tracking provider', async () => { + let exposure: Exposure; + const client = new ExperimentClient(API_KEY, { + debug: true, + exposureTrackingProvider: { + track: (e) => { + exposure = e; + }, + }, + }); + mockClientStorage(client); + await client.fetch({ user_id: 'test_user' }); + const variant = client.variant('sdk-ci-test'); + expect(exposure.flag_key).toEqual('sdk-ci-test'); + expect(exposure.variant).toEqual(variant.value); + }); + test('with plugin, user provider, and exposure tracking provider', async () => { + let providerExposure: Exposure; + let pluginExposure: ExperimentEvent; + const client = new ExperimentClient(API_KEY, { + debug: true, + userProvider: { + getUser: () => { + return { + user_id: 'user', + device_id: 'device', + user_properties: { k: 'v' }, + }; + }, + }, + exposureTrackingProvider: { + track: (e) => { + providerExposure = e; + }, + }, + }); + mockClientStorage(client); + client.addPlugin({ + type: 'integration', + setup: async () => { + return delay(100); + }, + getUser: () => { + return { + user_id: 'user2', + user_properties: { k2: 'v2' }, + }; + }, + track: (e) => { + pluginExposure = e; + return true; + }, + } as IntegrationPlugin); + const user = await client['addContextOrWait'](client.getUser()); + expect(user).toEqual({ + user_id: 'user2', + device_id: 'device', + library: `experiment-js-client/${PACKAGE_VERSION}`, + user_properties: { k: 'v', k2: 'v2' }, + }); + await client.fetch(testUser); + const variant = client.variant('sdk-ci-test'); + expect(providerExposure.flag_key).toEqual('sdk-ci-test'); + expect(providerExposure.variant).toEqual(variant.value); + expect(pluginExposure.eventProperties['flag_key']).toEqual('sdk-ci-test'); + expect(pluginExposure.eventProperties['variant']).toEqual(variant.value); + }); +}); diff --git a/packages/experiment-browser/test/defaultUserProvider.test.ts b/packages/experiment-browser/test/defaultUserProvider.test.ts index 0be8f6a0..efcf5ec1 100644 --- a/packages/experiment-browser/test/defaultUserProvider.test.ts +++ b/packages/experiment-browser/test/defaultUserProvider.test.ts @@ -1,19 +1,17 @@ -import { ApplicationContext } from '@amplitude/analytics-connector'; import * as coreUtil from '@amplitude/experiment-core'; import { ExperimentUser } from '../src'; -import { DefaultUserProvider } from '../src/integration/default'; +import { DefaultUserProvider } from '../src/providers/default'; describe('DefaultUserProvider', () => { const mockGetGlobalScope = jest.spyOn(coreUtil, 'getGlobalScope'); let mockGlobal; const defaultUser = { - language: 'language', - platform: 'platform', - os: 'os', - device_model: 'deviceModel', - version: 'versionName', + language: 'en-US', + platform: 'Web', + os: 'WebKit 537', browser: 'WebKit', + device_model: 'iPhone', device_category: 'desktop', referring_url: '', first_seen: '1000', @@ -24,13 +22,6 @@ describe('DefaultUserProvider', () => { }, url_param: { p1: ['p1v1', 'p1v2'], p2: ['p2v1', 'p2v2'], p3: 'p3v1' }, }; - const defaultApplicationContext = { - language: 'language', - platform: 'platform', - os: 'os', - deviceModel: 'deviceModel', - versionName: 'versionName', - }; let mockLocalStorage; let mockSessionStorage; @@ -56,6 +47,7 @@ describe('DefaultUserProvider', () => { document: { referrer: '', cookie: 'c1=v1; c2=v2' }, history: { replaceState: jest.fn() }, }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore mockGetGlobalScope.mockReturnValue(mockGlobal); @@ -69,20 +61,15 @@ describe('DefaultUserProvider', () => { k1: 'v1', }, }; - const applicationContext: ApplicationContext = defaultApplicationContext; - const defaultUserProvider = new DefaultUserProvider( - { - versionName: 'versionName', - getApplicationContext(): ApplicationContext { - return applicationContext; - }, - }, - { - getUser(): ExperimentUser { - return user; + const defaultUserProvider = mockProvider( + new DefaultUserProvider( + { + getUser(): ExperimentUser { + return user; + }, }, - }, - 'apikey', + 'apikey', + ), ); const actualUser = defaultUserProvider.getUser(); const expectedUser = { @@ -99,28 +86,15 @@ describe('DefaultUserProvider', () => { }); test('wrapped provider not set', async () => { - const applicationContext: ApplicationContext = defaultApplicationContext; - const defaultUserProvider = new DefaultUserProvider({ - versionName: 'versionName', - getApplicationContext(): ApplicationContext { - return applicationContext; - }, - }); + const defaultUserProvider = mockProvider(new DefaultUserProvider()); const actualUser = defaultUserProvider.getUser(); const expectedUser = defaultUser; expect(actualUser).toEqual(expectedUser); }); test('wrapped provider undefined', async () => { - const applicationContext: ApplicationContext = defaultApplicationContext; - const defaultUserProvider = new DefaultUserProvider( - { - versionName: 'versionName', - getApplicationContext(): ApplicationContext { - return applicationContext; - }, - }, - undefined, + const defaultUserProvider = mockProvider( + new DefaultUserProvider(undefined), ); const actualUser = defaultUserProvider.getUser(); const expectedUser = defaultUser; @@ -128,16 +102,7 @@ describe('DefaultUserProvider', () => { }); test('wrapped provider null', async () => { - const applicationContext: ApplicationContext = defaultApplicationContext; - const defaultUserProvider = new DefaultUserProvider( - { - versionName: 'versionName', - getApplicationContext(): ApplicationContext { - return applicationContext; - }, - }, - undefined, - ); + const defaultUserProvider = mockProvider(new DefaultUserProvider(null)); const actualUser = defaultUserProvider.getUser(); const expectedUser = defaultUser; expect(actualUser).toEqual(expectedUser); @@ -152,19 +117,12 @@ describe('DefaultUserProvider', () => { }, device_model: 'deviceModel2', }; - const applicationContext: ApplicationContext = defaultApplicationContext; - const defaultUserProvider = new DefaultUserProvider( - { - versionName: 'versionName', - getApplicationContext(): ApplicationContext { - return applicationContext; - }, - }, - { + const defaultUserProvider = mockProvider( + new DefaultUserProvider({ getUser(): ExperimentUser { return user; }, - }, + }), ); const actualUser = defaultUserProvider.getUser(); const expectedUser = { @@ -179,16 +137,8 @@ describe('DefaultUserProvider', () => { mockSessionStorage['EXP_apikey_DEFAULT_USER_PROVIDER'] = '{"landing_url": "http://testtest.com"}'; - const applicationContext: ApplicationContext = defaultApplicationContext; - const defaultUserProvider = new DefaultUserProvider( - { - versionName: 'versionName', - getApplicationContext(): ApplicationContext { - return applicationContext; - }, - }, - undefined, - 'apikey', + const defaultUserProvider = mockProvider( + new DefaultUserProvider(undefined, 'apikey'), ); const actualUser = defaultUserProvider.getUser(); const expectedUser = { @@ -199,3 +149,11 @@ describe('DefaultUserProvider', () => { expect(actualUser).toEqual(expectedUser); }); }); + +const mockProvider = (provider: DefaultUserProvider): DefaultUserProvider => { + provider['getLanguage'] = () => 'en-US'; + provider['getBrowser'] = () => 'WebKit'; + provider['getOs'] = () => 'WebKit 537'; + provider['getDeviceModel'] = () => 'iPhone'; + return provider; +}; diff --git a/packages/experiment-browser/test/integration/amplitude.test.ts b/packages/experiment-browser/test/integration/amplitude.test.ts new file mode 100644 index 00000000..4b04cbd7 --- /dev/null +++ b/packages/experiment-browser/test/integration/amplitude.test.ts @@ -0,0 +1,257 @@ +import { + AnalyticsConnector, + AnalyticsEvent, +} from '@amplitude/analytics-connector'; +import { safeGlobal } from '@amplitude/experiment-core'; +import { AmplitudeIntegrationPlugin } from 'src/integration/amplitude'; +import { AmplitudeState } from 'src/util/state'; + +import { clearAllCookies } from '../util/misc'; + +const apiKey = '1234567890abcdefabcdefabcdefabcd'; + +describe('AmplitudeIntegrationPlugin', () => { + let connector: AnalyticsConnector; + beforeEach(() => { + clearAllCookies(); + safeGlobal.localStorage.clear(); + safeGlobal.sessionStorage.clear(); + safeGlobal['analyticsConnectorInstances'] = {}; + connector = AnalyticsConnector.getInstance('$default_instance'); + }); + describe('constructor', () => { + test('user loaded from legacy cookie, setup undefined, connector identity set', () => { + const cookieValue = `device.${btoa('user')}........`; + safeGlobal.document.cookie = `amp_123456=${cookieValue}`; + const integration = new AmplitudeIntegrationPlugin( + apiKey, + connector.identityStore, + connector.eventBridge, + connector.applicationContextProvider, + 10000, + ); + expect(integration.setup).toBeUndefined(); + expect(connector.identityStore.getIdentity()).toEqual({ + userId: 'user', + deviceId: 'device', + userProperties: {}, + }); + }); + test('user loaded from cookie, setup undefined, connector identity set', () => { + const state: AmplitudeState = { + userId: 'user', + deviceId: 'device', + }; + const cookieValue = btoa(encodeURIComponent(JSON.stringify(state))); + safeGlobal.document.cookie = `AMP_1234567890=${cookieValue}`; + const integration = new AmplitudeIntegrationPlugin( + apiKey, + connector.identityStore, + connector.eventBridge, + connector.applicationContextProvider, + 10000, + ); + expect(integration.setup).toBeUndefined(); + expect(connector.identityStore.getIdentity()).toEqual({ + userId: 'user', + deviceId: 'device', + userProperties: {}, + }); + }); + test('user loaded from local storage, setup undefined, connector identity set', () => { + const state: AmplitudeState = { + userId: 'user', + deviceId: 'device', + }; + safeGlobal.localStorage.setItem('AMP_1234567890', JSON.stringify(state)); + const integration = new AmplitudeIntegrationPlugin( + apiKey, + connector.identityStore, + connector.eventBridge, + connector.applicationContextProvider, + 10000, + ); + expect(integration.setup).toBeUndefined(); + expect(connector.identityStore.getIdentity()).toEqual({ + userId: 'user', + deviceId: 'device', + userProperties: {}, + }); + }); + test('user loaded from session storage, setup undefined, connector identity set', () => { + const state: AmplitudeState = { + userId: 'user', + deviceId: 'device', + }; + safeGlobal.sessionStorage.setItem( + 'AMP_1234567890', + JSON.stringify(state), + ); + const integration = new AmplitudeIntegrationPlugin( + apiKey, + connector.identityStore, + connector.eventBridge, + connector.applicationContextProvider, + 10000, + ); + expect(integration.setup).toBeUndefined(); + expect(connector.identityStore.getIdentity()).toEqual({ + userId: 'user', + deviceId: 'device', + userProperties: {}, + }); + }); + test('user not loaded, setup defined, connector identity empty', () => { + const integration = new AmplitudeIntegrationPlugin( + apiKey, + connector.identityStore, + connector.eventBridge, + connector.applicationContextProvider, + 10000, + ); + expect(integration.setup).toBeDefined(); + expect(connector.identityStore.getIdentity()).toEqual({ + userProperties: {}, + }); + }); + }); + describe('setup', () => { + test('setup times out', async () => { + const integration = new AmplitudeIntegrationPlugin( + apiKey, + connector.identityStore, + connector.eventBridge, + connector.applicationContextProvider, + 100, + ); + try { + await Promise.race([ + integration.setup(), + new Promise((resolve) => setTimeout(() => resolve(), 500)), + ]); + fail('expected setup() to throw an error'); + } catch (e) { + // Expected + } + }); + test('setup resolves when connector identity set before', async () => { + const integration = new AmplitudeIntegrationPlugin( + apiKey, + connector.identityStore, + connector.eventBridge, + connector.applicationContextProvider, + 10000, + ); + connector.identityStore.setIdentity({ + userId: 'user', + deviceId: 'device', + }); + await Promise.race([ + integration.setup(), + new Promise((_, reject) => setTimeout(() => reject('timeout'), 500)), + ]); + }); + test('setup resolves when connector identity set after', async () => { + const integration = new AmplitudeIntegrationPlugin( + apiKey, + connector.identityStore, + connector.eventBridge, + connector.applicationContextProvider, + 10000, + ); + const race = Promise.race([ + integration.setup(), + new Promise((_, reject) => setTimeout(() => reject('timeout'), 500)), + ]); + setTimeout(() => { + connector.identityStore.setIdentity({ + userId: 'user', + deviceId: 'device', + }); + }, 100); + await race; + }); + }); + describe('getUser', () => { + test('returns empty user', () => { + const integration = new AmplitudeIntegrationPlugin( + apiKey, + connector.identityStore, + connector.eventBridge, + connector.applicationContextProvider, + 10000, + ); + expect(integration.getUser()).toEqual({ user_properties: {} }); + }); + test('returns expected properties from connector', () => { + const integration = new AmplitudeIntegrationPlugin( + apiKey, + connector.identityStore, + connector.eventBridge, + connector.applicationContextProvider, + 10000, + ); + connector.identityStore.setIdentity({ + userId: 'user', + deviceId: 'device', + userProperties: { k: 'v' }, + }); + connector.applicationContextProvider.versionName = '1.0.0'; + expect(integration.getUser()).toEqual({ + user_id: 'user', + device_id: 'device', + user_properties: { k: 'v' }, + version: '1.0.0', + }); + }); + }); + describe('track', () => { + test('event bridge receiver not set, returns false', () => { + const integration = new AmplitudeIntegrationPlugin( + apiKey, + connector.identityStore, + connector.eventBridge, + connector.applicationContextProvider, + 10000, + ); + expect( + integration.track({ + eventType: '$exposure', + eventProperties: { + flag_key: 'flag-key', + variant: 'on', + }, + }), + ).toEqual(false); + }); + test('event bridge receiver set, calls connector event bridge, returns true', () => { + const integration = new AmplitudeIntegrationPlugin( + apiKey, + connector.identityStore, + connector.eventBridge, + connector.applicationContextProvider, + 10000, + ); + let event: AnalyticsEvent; + connector.eventBridge.setEventReceiver((e) => { + event = e; + }); + expect( + integration.track({ + eventType: '$exposure', + eventProperties: { + flag_key: 'flag-key', + variant: 'on', + }, + }), + ).toEqual(true); + expect(event).toEqual({ + eventType: '$exposure', + eventProperties: { + flag_key: 'flag-key', + variant: 'on', + }, + }); + }); + }); +}); diff --git a/packages/experiment-browser/test/integration/manager.test.ts b/packages/experiment-browser/test/integration/manager.test.ts new file mode 100644 index 00000000..47c8d0e9 --- /dev/null +++ b/packages/experiment-browser/test/integration/manager.test.ts @@ -0,0 +1,358 @@ +import { safeGlobal } from '@amplitude/experiment-core'; +import { ExperimentConfig } from 'src/config'; +import { ExperimentClient } from 'src/experimentClient'; +import { + IntegrationManager, + PersistentTrackingQueue, + SessionDedupeCache, +} from 'src/integration/manager'; +import { ExperimentEvent } from 'src/types/plugin'; + +describe('IntegrationManager', () => { + let manager: IntegrationManager; + beforeEach(() => { + safeGlobal.localStorage.clear(); + const config = { test: 'config' } as ExperimentConfig; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const client = { test: 'client' } as ExperimentClient; + manager = new IntegrationManager(config, client); + }); + + describe('ready', () => { + test('no integration, resolved', async () => { + await Promise.race([ + manager.ready(), + new Promise((_, reject) => setTimeout(() => reject('timeout'), 100)), + ]); + }); + }); + describe('setIntegration', () => { + test('no, integration, setup not defined, ready resolves', async () => { + manager.setIntegration({ + type: 'integration', + getUser: () => { + return {}; + }, + track: (): boolean => { + return true; + }, + }); + await Promise.race([ + manager.ready(), + new Promise((_, reject) => setTimeout(() => reject('timeout'), 100)), + ]); + expect(manager['queue']['tracker']).toBeDefined(); + }); + test('no integration, setup defined, setup called', async () => { + let setupCalled = false; + manager.setIntegration({ + type: 'integration', + setup: async () => { + setupCalled = true; + }, + getUser: () => { + return {}; + }, + track: (): boolean => { + return true; + }, + }); + await Promise.race([ + manager.ready(), + new Promise((_, reject) => setTimeout(() => reject('timeout'), 100)), + ]); + expect(setupCalled).toBe(true); + expect(manager['queue']['tracker']).toBeDefined(); + }); + test('setup throws, resolved', async () => { + let setupCalled = false; + manager.setIntegration({ + type: 'integration', + setup: async () => { + setupCalled = true; + throw new Error('failure'); + }, + getUser: () => { + return {}; + }, + track: (): boolean => { + return true; + }, + }); + await Promise.race([ + manager.ready(), + new Promise((_, reject) => setTimeout(() => reject('timeout'), 100)), + ]); + expect(setupCalled).toBe(true); + expect(manager['queue']['tracker']).toBeDefined(); + }); + test('existing integration teardown called', async () => { + let teardownCalled = false; + manager.setIntegration({ + type: 'integration', + teardown: async () => { + teardownCalled = true; + }, + getUser: () => { + return {}; + }, + track: (): boolean => { + return true; + }, + }); + manager.setIntegration({ + type: 'integration', + getUser: () => { + return {}; + }, + track: (): boolean => { + return true; + }, + }); + expect(teardownCalled).toBe(true); + }); + }); + describe('getUser', () => { + test('no integration, returns empty object', async () => { + expect(manager.getUser()).toEqual({}); + }); + test('with integration, calls integration', async () => { + manager.setIntegration({ + type: 'integration', + getUser: () => { + return { + user_id: 'userId', + device_id: 'deviceId', + }; + }, + track: (): boolean => { + return true; + }, + }); + expect(manager.getUser()).toEqual({ + user_id: 'userId', + device_id: 'deviceId', + }); + }); + }); + describe('track', () => { + test('correct event pushed to queue', async () => { + manager.track({ + flag_key: 'flag-key', + variant: 'treatment', + experiment_key: 'exp-1', + metadata: { + test: 'test', + }, + }); + expect(manager['queue']['inMemoryQueue'][0]).toEqual({ + eventType: '$exposure', + eventProperties: { + flag_key: 'flag-key', + variant: 'treatment', + experiment_key: 'exp-1', + metadata: { + test: 'test', + }, + }, + }); + }); + }); +}); + +describe('SessionDedupeCache', () => { + beforeEach(() => { + safeGlobal.sessionStorage.clear(); + }); + test('test storage key', () => { + const instanceName = '$default_instance'; + const cache = new SessionDedupeCache(instanceName); + expect(cache['storageKey']).toEqual('EXP_sent_$default_instance'); + }); + test('should track with empty storage returns true, sets storage', () => { + const instanceName = '$default_instance'; + const cache = new SessionDedupeCache(instanceName); + const exposure = { + flag_key: 'flag-key', + variant: 'on', + }; + expect(cache.shouldTrack(exposure)).toEqual(true); + const storedCache = JSON.parse( + safeGlobal.sessionStorage.getItem(cache['storageKey']), + ); + const expected = { 'flag-key': 'on' }; + expect(storedCache).toEqual(expected); + expect(cache['inMemoryCache']).toEqual(expected); + }); + test('should track with entry in storage returns false, storage unchanged', () => { + const instanceName = '$default_instance'; + const cache = new SessionDedupeCache(instanceName); + const exposure = { + flag_key: 'flag-key', + variant: 'on', + }; + safeGlobal.sessionStorage.setItem( + 'EXP_sent_$default_instance', + JSON.stringify({ [`${exposure.flag_key}`]: exposure.variant }), + ); + expect(cache.shouldTrack(exposure)).toEqual(false); + const storedCache = JSON.parse( + safeGlobal.sessionStorage.getItem('EXP_sent_$default_instance'), + ); + const expected = { 'flag-key': 'on' }; + expect(storedCache).toEqual(expected); + expect(cache['inMemoryCache']).toEqual(expected); + }); + test('should track with different entry in storage returns true, sets storage', () => { + const instanceName = '$default_instance'; + const cache = new SessionDedupeCache(instanceName); + const exposure = { + flag_key: 'flag-key', + variant: 'on', + }; + safeGlobal.sessionStorage.setItem( + 'EXP_sent_$default_instance', + JSON.stringify({ [`${exposure.flag_key}-2`]: exposure.variant }), + ); + expect(cache.shouldTrack(exposure)).toEqual(true); + const storedCache = JSON.parse( + safeGlobal.sessionStorage.getItem('EXP_sent_$default_instance'), + ); + const expected = { + 'flag-key': 'on', + 'flag-key-2': 'on', + }; + expect(storedCache).toEqual(expected); + expect(cache['inMemoryCache']).toEqual(expected); + }); +}); + +describe('PersistentTrackingQueue', () => { + beforeEach(() => { + safeGlobal.localStorage.clear(); + }); + + test('test storage key', () => { + const instanceName = '$default_instance'; + const queue = new PersistentTrackingQueue(instanceName); + expect(queue['storageKey']).toEqual('EXP_unsent_$default_instance'); + }); + + test('push, no tracker', () => { + const instanceName = '$default_instance'; + const queue = new PersistentTrackingQueue(instanceName); + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'flag-key', + variant: 'on', + }, + }; + + queue.push(event); + expect(queue['inMemoryQueue']).toEqual([event]); + expect(safeGlobal.localStorage.getItem(queue['storageKey'])).toEqual( + JSON.stringify([event]), + ); + + queue.push(event); + expect(queue['inMemoryQueue']).toEqual([event, event]); + expect(safeGlobal.localStorage.getItem(queue['storageKey'])).toEqual( + JSON.stringify([event, event]), + ); + }); + + test('push, with tracker returns false', () => { + const instanceName = '$default_instance'; + const queue = new PersistentTrackingQueue(instanceName); + const trackedEvents: ExperimentEvent[] = []; + queue.tracker = (event) => { + trackedEvents.push(event); + return false; + }; + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'flag-key', + variant: 'on', + }, + }; + + queue.push(event); + expect(queue['inMemoryQueue']).toEqual([event]); + expect(safeGlobal.localStorage.getItem(queue['storageKey'])).toEqual( + JSON.stringify([event]), + ); + expect(trackedEvents).toEqual([event]); + + queue.push(event); + expect(queue['inMemoryQueue']).toEqual([event, event]); + expect(safeGlobal.localStorage.getItem(queue['storageKey'])).toEqual( + JSON.stringify([event, event]), + ); + expect(trackedEvents).toEqual([event, event]); + }); + + test('push, with tracker returns true', () => { + const instanceName = '$default_instance'; + const queue = new PersistentTrackingQueue(instanceName); + const trackedEvents: ExperimentEvent[] = []; + queue.tracker = (event) => { + trackedEvents.push(event); + return true; + }; + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'flag-key', + variant: 'on', + }, + }; + + queue.push(event); + expect(queue['inMemoryQueue']).toEqual([]); + expect(safeGlobal.localStorage.getItem(queue['storageKey'])).toEqual( + JSON.stringify([]), + ); + expect(trackedEvents).toEqual([event]); + + queue.push(event); + expect(queue['inMemoryQueue']).toEqual([]); + expect(safeGlobal.localStorage.getItem(queue['storageKey'])).toEqual( + JSON.stringify([]), + ); + expect(trackedEvents).toEqual([event, event]); + }); + + test('push, late set tracker', () => { + const instanceName = '$default_instance'; + const queue = new PersistentTrackingQueue(instanceName); + const trackedEvents: ExperimentEvent[] = []; + const event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: { + flag_key: 'flag-key', + variant: 'on', + }, + }; + + queue.push(event); + expect(queue['inMemoryQueue']).toEqual([event]); + expect(safeGlobal.localStorage.getItem(queue['storageKey'])).toEqual( + JSON.stringify([event]), + ); + + queue.tracker = (event) => { + trackedEvents.push(event); + return true; + }; + + queue.push(event); + expect(queue['inMemoryQueue']).toEqual([]); + expect(safeGlobal.localStorage.getItem(queue['storageKey'])).toEqual( + JSON.stringify([]), + ); + expect(trackedEvents).toEqual([event, event]); + }); +}); diff --git a/packages/experiment-browser/test/util/misc.ts b/packages/experiment-browser/test/util/misc.ts new file mode 100644 index 00000000..0076dd2b --- /dev/null +++ b/packages/experiment-browser/test/util/misc.ts @@ -0,0 +1,8 @@ +export const clearAllCookies = () => { + const cookies = document.cookie.split(';'); + + for (const cookie of cookies) { + const cookieName = cookie.split('=')[0].trim(); + document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; + } +}; diff --git a/packages/experiment-browser/test/util/state.test.ts b/packages/experiment-browser/test/util/state.test.ts new file mode 100644 index 00000000..7d6ae790 --- /dev/null +++ b/packages/experiment-browser/test/util/state.test.ts @@ -0,0 +1,156 @@ +import { safeGlobal } from '@amplitude/experiment-core'; +import { + AmplitudeState, + parseAmplitudeCookie, + parseAmplitudeLocalStorage, + parseAmplitudeSessionStorage, +} from 'src/util/state'; +import { clearAllCookies } from './misc'; + +const apiKey = '1234567890abcdefabcdefabcdefabcd'; + +describe('parseAmplitudeCookie', () => { + beforeEach(() => { + clearAllCookies(); + }); + describe('new format', () => { + test('amplitude cookie exists with valid format', () => { + const state: AmplitudeState = { + userId: 'user', + deviceId: 'device', + }; + const cookieValue = btoa(encodeURIComponent(JSON.stringify(state))); + safeGlobal.document.cookie = 'k1=v1'; + safeGlobal.document.cookie = `AMP_1234567890=${cookieValue}`; + safeGlobal.document.cookie = 'k3=v3'; + const result = parseAmplitudeCookie(apiKey, true); + expect(result).toEqual(state); + }); + test('amplitude cookie exists with invalid format', () => { + const state: AmplitudeState = { + userId: 'user', + deviceId: 'device', + }; + const cookieValue = JSON.stringify(state); + safeGlobal.document.cookie = 'k1=v1'; + safeGlobal.document.cookie = `AMP_1234567890=${cookieValue}`; + safeGlobal.document.cookie = 'k3=v3'; + const result = parseAmplitudeCookie(apiKey, true); + expect(result).toBeUndefined(); + }); + test('amplitude cookie exists with different api key', () => { + const state: AmplitudeState = { + userId: 'user', + deviceId: 'device', + }; + const cookieValue = btoa(encodeURIComponent(JSON.stringify(state))); + safeGlobal.document.cookie = 'k1=v1'; + safeGlobal.document.cookie = `AMP_abcdefabcd=${cookieValue}`; + safeGlobal.document.cookie = 'k3=v3'; + const result = parseAmplitudeCookie(apiKey, true); + expect(result).toBeUndefined(); + }); + test('amplitude cookie does not exist', () => { + const result = parseAmplitudeCookie(apiKey, true); + expect(result).toBeUndefined(); + }); + }); + describe('old format', () => { + test('amplitude cookie exists with valid format', () => { + const cookieValue = `device.${btoa('user')}........`; + safeGlobal.document.cookie = 'k1=v1'; + safeGlobal.document.cookie = `amp_123456=${cookieValue}`; + safeGlobal.document.cookie = 'k3=v3'; + const result = parseAmplitudeCookie(apiKey, false); + expect(result).toEqual({ + userId: 'user', + deviceId: 'device', + }); + }); + test('amplitude cookie exists with different api key', () => { + const cookieValue = `device.${btoa('user')}........`; + safeGlobal.document.cookie = 'k1=v1'; + safeGlobal.document.cookie = `amp_abcdef=${cookieValue}`; + safeGlobal.document.cookie = 'k3=v3'; + const result = parseAmplitudeCookie(apiKey, false); + expect(result).toBeUndefined(); + }); + test('amplitude cookie exists with empty string', () => { + safeGlobal.document.cookie = 'k1=v1'; + safeGlobal.document.cookie = `amp_123456=`; + safeGlobal.document.cookie = 'k3=v3'; + const result = parseAmplitudeCookie(apiKey, false); + expect(result).toBeUndefined(); + }); + test('amplitude cookie does not exist', () => { + const result = parseAmplitudeCookie(apiKey, false); + expect(result).toBeUndefined(); + }); + }); +}); + +describe('parseAmplitudeLocalStorage', () => { + beforeEach(() => { + safeGlobal.localStorage.clear(); + }); + test('state exists with valid format', () => { + const state: AmplitudeState = { + userId: 'user', + deviceId: 'device', + }; + safeGlobal.localStorage.setItem('AMP_1234567890', JSON.stringify(state)); + const result = parseAmplitudeLocalStorage(apiKey); + expect(result).toEqual(state); + }); + test('state exists with invalid format', () => { + safeGlobal.localStorage.setItem('AMP_1234567890', JSON.stringify('asdf')); + const result = parseAmplitudeLocalStorage(apiKey); + expect(result).toBeUndefined(); + }); + test('state exists with different api key', () => { + const state: AmplitudeState = { + userId: 'user', + deviceId: 'device', + }; + safeGlobal.localStorage.setItem('AMP_abcdefabcd', JSON.stringify(state)); + const result = parseAmplitudeLocalStorage(apiKey); + expect(result).toBeUndefined(); + }); + test('state does not exist', () => { + const result = parseAmplitudeLocalStorage(apiKey); + expect(result).toBeUndefined(); + }); +}); + +describe('parseAmplitudeSessionStorage', () => { + beforeEach(() => { + safeGlobal.sessionStorage.clear(); + }); + test('state exists with valid format', () => { + const state: AmplitudeState = { + userId: 'user', + deviceId: 'device', + }; + safeGlobal.sessionStorage.setItem('AMP_1234567890', JSON.stringify(state)); + const result = parseAmplitudeSessionStorage(apiKey); + expect(result).toEqual(state); + }); + test('state exists with invalid format', () => { + safeGlobal.sessionStorage.setItem('AMP_1234567890', JSON.stringify('asdf')); + const result = parseAmplitudeSessionStorage(apiKey); + expect(result).toBeUndefined(); + }); + test('state exists with different api key', () => { + const state: AmplitudeState = { + userId: 'user', + deviceId: 'device', + }; + safeGlobal.sessionStorage.setItem('AMP_abcdefabcd', JSON.stringify(state)); + const result = parseAmplitudeSessionStorage(apiKey); + expect(result).toBeUndefined(); + }); + test('state does not exist', () => { + const result = parseAmplitudeSessionStorage(apiKey); + expect(result).toBeUndefined(); + }); +}); From 1e0a118e5326f4e2165680b8a02e041d09828ebf Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Tue, 17 Sep 2024 09:35:10 -0700 Subject: [PATCH 02/24] fix: update comment --- packages/experiment-browser/src/experimentClient.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/experiment-browser/src/experimentClient.ts b/packages/experiment-browser/src/experimentClient.ts index 3ae6887b..afe9f984 100644 --- a/packages/experiment-browser/src/experimentClient.ts +++ b/packages/experiment-browser/src/experimentClient.ts @@ -857,9 +857,8 @@ export class ExperimentClient implements Client { } /** - * Private for now. Should only be used by web experiment. - * @param plugin - * @private + * Add a plugin to the experiment client. + * @param plugin the plugin to add. */ public addPlugin(plugin: ExperimentPlugin): void { if (plugin.type === 'integration') { From eadceb461cb09a1c2c9de7d246c28a7266089865 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Tue, 17 Sep 2024 11:35:39 -0700 Subject: [PATCH 03/24] fix: add comments; max queue size --- .../src/integration/amplitude.ts | 23 ++++++++++- .../src/integration/manager.ts | 39 ++++++++++++++++++- .../test/integration/manager.test.ts | 36 +++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/packages/experiment-browser/src/integration/amplitude.ts b/packages/experiment-browser/src/integration/amplitude.ts index 8e07aed7..e26009fd 100644 --- a/packages/experiment-browser/src/integration/amplitude.ts +++ b/packages/experiment-browser/src/integration/amplitude.ts @@ -14,6 +14,25 @@ import { parseAmplitudeSessionStorage, } from '../util/state'; +/** + * 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 + * + * If none of these locations contain the user identity, we set the setup() + * function to wait for the identity to be provided by the connector. + * + * 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. + */ export class AmplitudeIntegrationPlugin implements IntegrationPlugin { type: 'integration'; private readonly apiKey: string | undefined; @@ -68,7 +87,9 @@ export class AmplitudeIntegrationPlugin implements IntegrationPlugin { } private loadPersistedState(): boolean { - if (!this.apiKey) { + // 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 diff --git a/packages/experiment-browser/src/integration/manager.ts b/packages/experiment-browser/src/integration/manager.ts index c078d1aa..f81c0847 100644 --- a/packages/experiment-browser/src/integration/manager.ts +++ b/packages/experiment-browser/src/integration/manager.ts @@ -10,6 +10,11 @@ import { Exposure } from '../types/exposure'; import { ExperimentEvent, IntegrationPlugin } from '../types/plugin'; import { ExperimentUser } from '../types/user'; +const MAX_QUEUE_SIZE = 512; + +/** + * Handles integration plugin management, event persistence and deduplication. + */ export class IntegrationManager { private readonly config: ExperimentConfig; private readonly client: Client; @@ -30,6 +35,10 @@ export class IntegrationManager { 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(): Promise { if (!this.integration) { return Promise.resolve(); @@ -37,8 +46,15 @@ export class IntegrationManager { 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: IntegrationPlugin): void { - if (this.integration) { + if (this.integration && this.integration.teardown) { void this.integration.teardown(); } this.integration = integration; @@ -60,6 +76,10 @@ export class IntegrationManager { } } + /** + * Get the user from the integration. If no integration is set, returns an + * empty object. + */ getUser(): ExperimentUser { if (!this.integration) { return {}; @@ -67,6 +87,13 @@ export class IntegrationManager { 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: Exposure): void { if (this.cache.shouldTrack(exposure)) { this.queue.push({ @@ -117,13 +144,15 @@ export class SessionDedupeCache { export class PersistentTrackingQueue { private readonly storageKey: string; + private readonly maxQueueSize: number; private readonly isLocalStorageAvailable = isLocalStorageAvailable(); private inMemoryQueue: ExperimentEvent[] = []; tracker: ((event: ExperimentEvent) => boolean) | undefined; - constructor(instanceName: string) { + constructor(instanceName: string, maxQueueSize: number = MAX_QUEUE_SIZE) { this.storageKey = `EXP_unsent_${instanceName}`; + this.maxQueueSize = maxQueueSize; } push(event: ExperimentEvent): void { @@ -151,6 +180,12 @@ export class PersistentTrackingQueue { private storeQueue(): void { 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), diff --git a/packages/experiment-browser/test/integration/manager.test.ts b/packages/experiment-browser/test/integration/manager.test.ts index 47c8d0e9..aa1eaaa6 100644 --- a/packages/experiment-browser/test/integration/manager.test.ts +++ b/packages/experiment-browser/test/integration/manager.test.ts @@ -112,6 +112,26 @@ describe('IntegrationManager', () => { }); expect(teardownCalled).toBe(true); }); + test('existing integration without teardown', async () => { + manager.setIntegration({ + type: 'integration', + getUser: () => { + return {}; + }, + track: (): boolean => { + return true; + }, + }); + manager.setIntegration({ + type: 'integration', + getUser: () => { + return {}; + }, + track: (): boolean => { + return true; + }, + }); + }); }); describe('getUser', () => { test('no integration, returns empty object', async () => { @@ -355,4 +375,20 @@ describe('PersistentTrackingQueue', () => { ); expect(trackedEvents).toEqual([event, event]); }); + + test('oldest events over max queue size are trimmed', () => { + const maxQueueSize = 5; + const instanceName = '$default_instance'; + const queue = new PersistentTrackingQueue(instanceName, maxQueueSize); + for (let i = 0; i < maxQueueSize + 1; i++) { + queue.push({ eventType: `${i}` }); + } + expect(queue['inMemoryQueue']).toEqual([ + { eventType: '1' }, + { eventType: '2' }, + { eventType: '3' }, + { eventType: '4' }, + { eventType: '5' }, + ]); + }); }); From cc5016f066380118bd79f23f09bd8c8bd88eb3e5 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Tue, 17 Sep 2024 11:44:11 -0700 Subject: [PATCH 04/24] fix: lint --- packages/experiment-browser/src/factory.ts | 2 +- packages/experiment-browser/test/client.test.ts | 4 ++-- packages/experiment-browser/test/util/state.test.ts | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/experiment-browser/src/factory.ts b/packages/experiment-browser/src/factory.ts index 01ed41a9..17b57721 100644 --- a/packages/experiment-browser/src/factory.ts +++ b/packages/experiment-browser/src/factory.ts @@ -2,8 +2,8 @@ import { AnalyticsConnector } from '@amplitude/analytics-connector'; import { Defaults, ExperimentConfig } from './config'; import { ExperimentClient } from './experimentClient'; -import { DefaultUserProvider } from './providers/default'; import { AmplitudeIntegrationPlugin } from './integration/amplitude'; +import { DefaultUserProvider } from './providers/default'; const instances = {}; diff --git a/packages/experiment-browser/test/client.test.ts b/packages/experiment-browser/test/client.test.ts index c8f537b4..08a997c5 100644 --- a/packages/experiment-browser/test/client.test.ts +++ b/packages/experiment-browser/test/client.test.ts @@ -1,6 +1,8 @@ import { AnalyticsConnector } from '@amplitude/analytics-connector'; import { FetchError, safeGlobal } from '@amplitude/experiment-core'; +import { ExperimentEvent, IntegrationPlugin } from 'src/types/plugin'; +import { version as PACKAGE_VERSION } from '../package.json'; import { ExperimentAnalyticsProvider, ExperimentClient, @@ -15,10 +17,8 @@ import { } from '../src'; import { HttpClient, SimpleResponse } from '../src/types/transport'; import { randomString } from '../src/util/randomstring'; -import { version as PACKAGE_VERSION } from '../package.json'; import { mockClientStorage } from './util/mock'; -import { ExperimentEvent, IntegrationPlugin } from 'src/types/plugin'; const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)); diff --git a/packages/experiment-browser/test/util/state.test.ts b/packages/experiment-browser/test/util/state.test.ts index 7d6ae790..6d4d4e26 100644 --- a/packages/experiment-browser/test/util/state.test.ts +++ b/packages/experiment-browser/test/util/state.test.ts @@ -5,6 +5,7 @@ import { parseAmplitudeLocalStorage, parseAmplitudeSessionStorage, } from 'src/util/state'; + import { clearAllCookies } from './misc'; const apiKey = '1234567890abcdefabcdefabcdefabcd'; From f285b5d62d937655c3cff28f3fc9b406577d3683 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Tue, 17 Sep 2024 16:00:06 -0700 Subject: [PATCH 05/24] fix: dont cache web exposures --- .../experiment-browser/src/integration/manager.ts | 4 ++++ .../test/integration/manager.test.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/experiment-browser/src/integration/manager.ts b/packages/experiment-browser/src/integration/manager.ts index f81c0847..e9c00b05 100644 --- a/packages/experiment-browser/src/integration/manager.ts +++ b/packages/experiment-browser/src/integration/manager.ts @@ -114,6 +114,10 @@ export class SessionDedupeCache { } shouldTrack(exposure: Exposure): boolean { + // Always track web impressions. + if (exposure.metadata['deliveryMethod'] === 'web') { + return true; + } this.loadCache(); const value = this.inMemoryCache[exposure.flag_key]; let shouldTrack = false; diff --git a/packages/experiment-browser/test/integration/manager.test.ts b/packages/experiment-browser/test/integration/manager.test.ts index aa1eaaa6..f12f47db 100644 --- a/packages/experiment-browser/test/integration/manager.test.ts +++ b/packages/experiment-browser/test/integration/manager.test.ts @@ -246,6 +246,21 @@ describe('SessionDedupeCache', () => { expect(storedCache).toEqual(expected); expect(cache['inMemoryCache']).toEqual(expected); }); + test('should track with web delivery method exposure, always true', () => { + const instanceName = '$default_instance'; + const cache = new SessionDedupeCache(instanceName); + const exposure = { + flag_key: 'flag-key', + variant: 'on', + metadata: { + deliveryMethod: 'web', + }, + }; + expect(cache.shouldTrack(exposure)).toEqual(true); + expect(safeGlobal.sessionStorage.getItem(cache['storageKey'])).toBeNull(); + expect(cache.shouldTrack(exposure)).toEqual(true); + expect(safeGlobal.sessionStorage.getItem(cache['storageKey'])).toBeNull(); + }); }); describe('PersistentTrackingQueue', () => { From a8d1ed2deb4ba27f13f0ac17e86e7315e5c973e9 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Tue, 17 Sep 2024 17:16:02 -0700 Subject: [PATCH 06/24] feat: track impressions for web experiments --- .../src/integration/manager.ts | 17 ++++++++++---- .../test/integration/manager.test.ts | 23 ++++++++++++++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/experiment-browser/src/integration/manager.ts b/packages/experiment-browser/src/integration/manager.ts index e9c00b05..f4e153b2 100644 --- a/packages/experiment-browser/src/integration/manager.ts +++ b/packages/experiment-browser/src/integration/manager.ts @@ -96,10 +96,19 @@ export class IntegrationManager { */ track(exposure: Exposure): void { if (this.cache.shouldTrack(exposure)) { - this.queue.push({ - eventType: '$exposure', - eventProperties: exposure, - }); + if (exposure.metadata['deliveryMethod'] === 'web') { + // Track impression + this.queue.push({ + eventType: '$impression', + eventProperties: exposure, + }); + } else { + // Track exposure + this.queue.push({ + eventType: '$exposure', + eventProperties: exposure, + }); + } } } } diff --git a/packages/experiment-browser/test/integration/manager.test.ts b/packages/experiment-browser/test/integration/manager.test.ts index f12f47db..c6126a63 100644 --- a/packages/experiment-browser/test/integration/manager.test.ts +++ b/packages/experiment-browser/test/integration/manager.test.ts @@ -157,7 +157,7 @@ describe('IntegrationManager', () => { }); }); describe('track', () => { - test('correct event pushed to queue', async () => { + test('correct event pushed to queue', () => { manager.track({ flag_key: 'flag-key', variant: 'treatment', @@ -178,6 +178,27 @@ describe('IntegrationManager', () => { }, }); }); + test('web exposure tracked as impression', () => { + manager.track({ + flag_key: 'flag-key', + variant: 'treatment', + experiment_key: 'exp-1', + metadata: { + deliveryMethod: 'web', + }, + }); + expect(manager['queue']['inMemoryQueue'][0]).toEqual({ + eventType: '$impression', + eventProperties: { + flag_key: 'flag-key', + variant: 'treatment', + experiment_key: 'exp-1', + metadata: { + deliveryMethod: 'web', + }, + }, + }); + }); }); }); From 9f8c6b8d1b6b550ca0adbf3d92e0b82df4805d69 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Tue, 17 Sep 2024 17:38:46 -0700 Subject: [PATCH 07/24] fix: undefined metadata --- packages/experiment-browser/src/integration/manager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/experiment-browser/src/integration/manager.ts b/packages/experiment-browser/src/integration/manager.ts index f4e153b2..d1100e22 100644 --- a/packages/experiment-browser/src/integration/manager.ts +++ b/packages/experiment-browser/src/integration/manager.ts @@ -96,7 +96,7 @@ export class IntegrationManager { */ track(exposure: Exposure): void { if (this.cache.shouldTrack(exposure)) { - if (exposure.metadata['deliveryMethod'] === 'web') { + if (exposure.metadata?.deliveryMethod === 'web') { // Track impression this.queue.push({ eventType: '$impression', @@ -124,7 +124,7 @@ export class SessionDedupeCache { shouldTrack(exposure: Exposure): boolean { // Always track web impressions. - if (exposure.metadata['deliveryMethod'] === 'web') { + if (exposure.metadata?.deliveryMethod === 'web') { return true; } this.loadCache(); From 7cd333eb04150b392b34b44d7c159e1e9a704b7b Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Wed, 18 Sep 2024 16:24:34 -0700 Subject: [PATCH 08/24] fix: undefined metadata --- packages/experiment-browser/src/integration/manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/experiment-browser/src/integration/manager.ts b/packages/experiment-browser/src/integration/manager.ts index e9c00b05..2fbf1171 100644 --- a/packages/experiment-browser/src/integration/manager.ts +++ b/packages/experiment-browser/src/integration/manager.ts @@ -115,7 +115,7 @@ export class SessionDedupeCache { shouldTrack(exposure: Exposure): boolean { // Always track web impressions. - if (exposure.metadata['deliveryMethod'] === 'web') { + if (exposure.metadata?.deliveryMethod === 'web') { return true; } this.loadCache(); From 99558870e1a8028acf266ea6d94e8aa93fe7af96 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Sat, 21 Sep 2024 11:45:52 -0700 Subject: [PATCH 09/24] feat: change amplitude integration plugin to be feat exp specific --- packages/experiment-browser/src/factory.ts | 46 +++++--------- .../src/integration/amplitude.ts | 35 ++++++----- .../test/integration/amplitude.test.ts | 61 ++++++++----------- 3 files changed, 60 insertions(+), 82 deletions(-) diff --git a/packages/experiment-browser/src/factory.ts b/packages/experiment-browser/src/factory.ts index 17b57721..49a200dc 100644 --- a/packages/experiment-browser/src/factory.ts +++ b/packages/experiment-browser/src/factory.ts @@ -7,6 +7,10 @@ import { DefaultUserProvider } from './providers/default'; const instances = {}; +const getInstanceName = (config: ExperimentConfig): string => { + return config?.instanceName || Defaults.instanceName; +}; + /** * Initializes a singleton {@link ExperimentClient} identified by the configured * instance name. @@ -20,7 +24,7 @@ const initialize = ( ): ExperimentClient => { // Store instances by appending the instance name and api key. Allows for // initializing multiple default instances for different api keys. - const instanceName = config?.instanceName || Defaults.instanceName; + const instanceName = getInstanceName(config); const instanceKey = `${instanceName}.${apiKey}`; if (!instances[instanceKey]) { config = { @@ -47,35 +51,17 @@ const initializeWithAmplitudeAnalytics = ( apiKey: string, config?: ExperimentConfig, ): ExperimentClient => { - // Store instances by appending the instance name and api key. Allows for - // initializing multiple default instances for different api keys. - const instanceName = config?.instanceName || Defaults.instanceName; - const instanceKey = `${instanceName}.${apiKey}`; - const connector = AnalyticsConnector.getInstance(instanceName); - if (!instances[instanceKey]) { - connector.eventBridge.setInstanceName(instanceName); - config = { - userProvider: new DefaultUserProvider(undefined, apiKey), - ...config, - }; - const client = new ExperimentClient(apiKey, config); - client.addPlugin( - new AmplitudeIntegrationPlugin( - apiKey, - connector.identityStore, - connector.eventBridge, - connector.applicationContextProvider, - 10000, - ), - ); - instances[instanceKey] = client; - if (config.automaticFetchOnAmplitudeIdentityChange) { - connector.identityStore.addIdentityListener(() => { - instances[instanceKey].fetch(); - }); - } - } - return instances[instanceKey]; + const instanceName = getInstanceName(config); + const client = initialize(apiKey, config); + client.addPlugin( + new AmplitudeIntegrationPlugin( + apiKey, + instanceName, + AnalyticsConnector.getInstance(instanceName), + 10000, + ), + ); + return client; }; /** diff --git a/packages/experiment-browser/src/integration/amplitude.ts b/packages/experiment-browser/src/integration/amplitude.ts index e26009fd..2dfbf703 100644 --- a/packages/experiment-browser/src/integration/amplitude.ts +++ b/packages/experiment-browser/src/integration/amplitude.ts @@ -1,10 +1,13 @@ import { + AnalyticsConnector, ApplicationContextProvider, EventBridge, IdentityStore, } from '@amplitude/analytics-connector'; import { safeGlobal } from '@amplitude/experiment-core'; +import { ExperimentConfig } from '../config'; +import { Client } from '../types/client'; import { ExperimentEvent, IntegrationPlugin } from '../types/plugin'; import { ExperimentUser, UserProperties } from '../types/user'; import { @@ -26,9 +29,6 @@ import { * - Local Storage * - Session Storage * - * If none of these locations contain the user identity, we set the setup() - * function to wait for the identity to be provided by the connector. - * * 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. @@ -41,26 +41,29 @@ export class AmplitudeIntegrationPlugin implements IntegrationPlugin { private readonly contextProvider: ApplicationContextProvider; private readonly timeoutMillis: number; - setup: (() => Promise) | undefined = undefined; - constructor( apiKey: string | undefined, - identityStore: IdentityStore, - eventBridge: EventBridge, - contextProvider: ApplicationContextProvider, + instanceName: string, + connector: AnalyticsConnector, timeoutMillis: number, ) { this.apiKey = apiKey; - this.identityStore = identityStore; - this.eventBridge = eventBridge; - this.contextProvider = contextProvider; + this.identityStore = connector.identityStore; + this.eventBridge = connector.eventBridge; + this.contextProvider = connector.applicationContextProvider; this.timeoutMillis = timeoutMillis; - const userLoaded = this.loadPersistedState(); - if (!userLoaded) { - this.setup = async (): Promise => { - return this.waitForConnectorIdentity(this.timeoutMillis); - }; + this.eventBridge.setInstanceName(instanceName); + this.loadPersistedState(); + } + + async setup?(config?: ExperimentConfig, client?: Client) { + // Setup automatic fetch on amplitude identity change. + if (config.automaticFetchOnAmplitudeIdentityChange) { + this.identityStore.addIdentityListener(() => { + client.fetch(); + }); } + return this.waitForConnectorIdentity(this.timeoutMillis); } getUser(): ExperimentUser { diff --git a/packages/experiment-browser/test/integration/amplitude.test.ts b/packages/experiment-browser/test/integration/amplitude.test.ts index 4b04cbd7..e353d1f5 100644 --- a/packages/experiment-browser/test/integration/amplitude.test.ts +++ b/packages/experiment-browser/test/integration/amplitude.test.ts @@ -9,6 +9,7 @@ import { AmplitudeState } from 'src/util/state'; import { clearAllCookies } from '../util/misc'; const apiKey = '1234567890abcdefabcdefabcdefabcd'; +const instanceName = '$default_instance'; describe('AmplitudeIntegrationPlugin', () => { let connector: AnalyticsConnector; @@ -25,9 +26,8 @@ describe('AmplitudeIntegrationPlugin', () => { safeGlobal.document.cookie = `amp_123456=${cookieValue}`; const integration = new AmplitudeIntegrationPlugin( apiKey, - connector.identityStore, - connector.eventBridge, - connector.applicationContextProvider, + instanceName, + connector, 10000, ); expect(integration.setup).toBeUndefined(); @@ -46,9 +46,8 @@ describe('AmplitudeIntegrationPlugin', () => { safeGlobal.document.cookie = `AMP_1234567890=${cookieValue}`; const integration = new AmplitudeIntegrationPlugin( apiKey, - connector.identityStore, - connector.eventBridge, - connector.applicationContextProvider, + instanceName, + connector, 10000, ); expect(integration.setup).toBeUndefined(); @@ -66,9 +65,8 @@ describe('AmplitudeIntegrationPlugin', () => { safeGlobal.localStorage.setItem('AMP_1234567890', JSON.stringify(state)); const integration = new AmplitudeIntegrationPlugin( apiKey, - connector.identityStore, - connector.eventBridge, - connector.applicationContextProvider, + instanceName, + connector, 10000, ); expect(integration.setup).toBeUndefined(); @@ -89,9 +87,8 @@ describe('AmplitudeIntegrationPlugin', () => { ); const integration = new AmplitudeIntegrationPlugin( apiKey, - connector.identityStore, - connector.eventBridge, - connector.applicationContextProvider, + instanceName, + connector, 10000, ); expect(integration.setup).toBeUndefined(); @@ -104,9 +101,8 @@ describe('AmplitudeIntegrationPlugin', () => { test('user not loaded, setup defined, connector identity empty', () => { const integration = new AmplitudeIntegrationPlugin( apiKey, - connector.identityStore, - connector.eventBridge, - connector.applicationContextProvider, + instanceName, + connector, 10000, ); expect(integration.setup).toBeDefined(); @@ -119,9 +115,8 @@ describe('AmplitudeIntegrationPlugin', () => { test('setup times out', async () => { const integration = new AmplitudeIntegrationPlugin( apiKey, - connector.identityStore, - connector.eventBridge, - connector.applicationContextProvider, + instanceName, + connector, 100, ); try { @@ -137,9 +132,8 @@ describe('AmplitudeIntegrationPlugin', () => { test('setup resolves when connector identity set before', async () => { const integration = new AmplitudeIntegrationPlugin( apiKey, - connector.identityStore, - connector.eventBridge, - connector.applicationContextProvider, + instanceName, + connector, 10000, ); connector.identityStore.setIdentity({ @@ -154,9 +148,8 @@ describe('AmplitudeIntegrationPlugin', () => { test('setup resolves when connector identity set after', async () => { const integration = new AmplitudeIntegrationPlugin( apiKey, - connector.identityStore, - connector.eventBridge, - connector.applicationContextProvider, + instanceName, + connector, 10000, ); const race = Promise.race([ @@ -176,9 +169,8 @@ describe('AmplitudeIntegrationPlugin', () => { test('returns empty user', () => { const integration = new AmplitudeIntegrationPlugin( apiKey, - connector.identityStore, - connector.eventBridge, - connector.applicationContextProvider, + instanceName, + connector, 10000, ); expect(integration.getUser()).toEqual({ user_properties: {} }); @@ -186,9 +178,8 @@ describe('AmplitudeIntegrationPlugin', () => { test('returns expected properties from connector', () => { const integration = new AmplitudeIntegrationPlugin( apiKey, - connector.identityStore, - connector.eventBridge, - connector.applicationContextProvider, + instanceName, + connector, 10000, ); connector.identityStore.setIdentity({ @@ -209,9 +200,8 @@ describe('AmplitudeIntegrationPlugin', () => { test('event bridge receiver not set, returns false', () => { const integration = new AmplitudeIntegrationPlugin( apiKey, - connector.identityStore, - connector.eventBridge, - connector.applicationContextProvider, + instanceName, + connector, 10000, ); expect( @@ -227,9 +217,8 @@ describe('AmplitudeIntegrationPlugin', () => { test('event bridge receiver set, calls connector event bridge, returns true', () => { const integration = new AmplitudeIntegrationPlugin( apiKey, - connector.identityStore, - connector.eventBridge, - connector.applicationContextProvider, + instanceName, + connector, 10000, ); let event: AnalyticsEvent; From f8f703073328da51c7a1df0b35fea26b96ecbfa6 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Sat, 21 Sep 2024 12:02:01 -0700 Subject: [PATCH 10/24] feat: remove persistence from analytics connector; fix tests --- .../src/analyticsConnector.ts | 17 +----- .../analytics-connector/src/eventBridge.ts | 52 ++--------------- packages/experiment-browser/src/factory.ts | 1 - .../src/integration/amplitude.ts | 6 +- .../test/integration/amplitude.test.ts | 56 +++---------------- 5 files changed, 18 insertions(+), 114 deletions(-) diff --git a/packages/analytics-connector/src/analyticsConnector.ts b/packages/analytics-connector/src/analyticsConnector.ts index 40ae975b..6efcb7b2 100644 --- a/packages/analytics-connector/src/analyticsConnector.ts +++ b/packages/analytics-connector/src/analyticsConnector.ts @@ -17,21 +17,6 @@ export class AnalyticsConnector { safeGlobal['analyticsConnectorInstances'][instanceName] = new AnalyticsConnector(); } - const instance = safeGlobal['analyticsConnectorInstances'][instanceName]; - // If the eventBridge is using old implementation, update with new instance - if (!instance.eventBridge.setInstanceName) { - const queue = instance.eventBridge.queue ?? []; - const receiver = instance.eventBridge.receiver; - instance.eventBridge = new EventBridgeImpl(); - instance.eventBridge.setInstanceName(instanceName); - // handle case when receiver was not set during previous initialization - if (receiver) { - instance.eventBridge.setEventReceiver(receiver); - } - for (const event of queue) { - instance.eventBridge.logEvent(event); - } - } - return instance; + return safeGlobal['analyticsConnectorInstances'][instanceName]; } } diff --git a/packages/analytics-connector/src/eventBridge.ts b/packages/analytics-connector/src/eventBridge.ts index 6215622e..e89ebffa 100644 --- a/packages/analytics-connector/src/eventBridge.ts +++ b/packages/analytics-connector/src/eventBridge.ts @@ -1,8 +1,3 @@ -import { - getGlobalScope, - isLocalStorageAvailable, -} from '@amplitude/experiment-core'; - export type AnalyticsEvent = { eventType: string; eventProperties?: Record; @@ -13,47 +8,17 @@ export type AnalyticsEventReceiver = (event: AnalyticsEvent) => void; export interface EventBridge { logEvent(event: AnalyticsEvent): void; - setEventReceiver(listener: AnalyticsEventReceiver): void; - - setInstanceName(instanceName: string): void; } export class EventBridgeImpl implements EventBridge { - private instanceName = ''; private receiver: AnalyticsEventReceiver; - private inMemoryQueue: AnalyticsEvent[] = []; - private globalScope = getGlobalScope(); - - private getStorageKey(): string { - return `EXP_unsent_${this.instanceName}`; - } - - private getQueue(): AnalyticsEvent[] { - if (isLocalStorageAvailable()) { - const storageKey = this.getStorageKey(); - const storedQueue = this.globalScope.localStorage.getItem(storageKey); - this.inMemoryQueue = storedQueue ? JSON.parse(storedQueue) : []; - } - return this.inMemoryQueue; - } - - private setQueue(queue: AnalyticsEvent[]): void { - this.inMemoryQueue = queue; - if (isLocalStorageAvailable()) { - this.globalScope.localStorage.setItem( - this.getStorageKey(), - JSON.stringify(queue), - ); - } - } + private queue: AnalyticsEvent[] = []; logEvent(event: AnalyticsEvent): void { if (!this.receiver) { - const queue = this.getQueue(); - if (queue.length < 512) { - queue.push(event); - this.setQueue(queue); + if (this.queue.length < 512) { + this.queue.push(event); } } else { this.receiver(event); @@ -62,16 +27,11 @@ export class EventBridgeImpl implements EventBridge { setEventReceiver(receiver: AnalyticsEventReceiver): void { this.receiver = receiver; - const queue = this.getQueue(); - if (queue.length > 0) { - queue.forEach((event) => { + if (this.queue.length > 0) { + this.queue.forEach((event) => { receiver(event); }); - this.setQueue([]); + this.queue = []; } } - - public setInstanceName(instanceName: string): void { - this.instanceName = instanceName; - } } diff --git a/packages/experiment-browser/src/factory.ts b/packages/experiment-browser/src/factory.ts index 49a200dc..15170497 100644 --- a/packages/experiment-browser/src/factory.ts +++ b/packages/experiment-browser/src/factory.ts @@ -56,7 +56,6 @@ const initializeWithAmplitudeAnalytics = ( client.addPlugin( new AmplitudeIntegrationPlugin( apiKey, - instanceName, AnalyticsConnector.getInstance(instanceName), 10000, ), diff --git a/packages/experiment-browser/src/integration/amplitude.ts b/packages/experiment-browser/src/integration/amplitude.ts index 2dfbf703..3c6cb722 100644 --- a/packages/experiment-browser/src/integration/amplitude.ts +++ b/packages/experiment-browser/src/integration/amplitude.ts @@ -43,7 +43,6 @@ export class AmplitudeIntegrationPlugin implements IntegrationPlugin { constructor( apiKey: string | undefined, - instanceName: string, connector: AnalyticsConnector, timeoutMillis: number, ) { @@ -52,15 +51,14 @@ export class AmplitudeIntegrationPlugin implements IntegrationPlugin { this.eventBridge = connector.eventBridge; this.contextProvider = connector.applicationContextProvider; this.timeoutMillis = timeoutMillis; - this.eventBridge.setInstanceName(instanceName); this.loadPersistedState(); } async setup?(config?: ExperimentConfig, client?: Client) { // Setup automatic fetch on amplitude identity change. - if (config.automaticFetchOnAmplitudeIdentityChange) { + if (config?.automaticFetchOnAmplitudeIdentityChange) { this.identityStore.addIdentityListener(() => { - client.fetch(); + client?.fetch(); }); } return this.waitForConnectorIdentity(this.timeoutMillis); diff --git a/packages/experiment-browser/test/integration/amplitude.test.ts b/packages/experiment-browser/test/integration/amplitude.test.ts index e353d1f5..13d97b90 100644 --- a/packages/experiment-browser/test/integration/amplitude.test.ts +++ b/packages/experiment-browser/test/integration/amplitude.test.ts @@ -9,7 +9,6 @@ import { AmplitudeState } from 'src/util/state'; import { clearAllCookies } from '../util/misc'; const apiKey = '1234567890abcdefabcdefabcdefabcd'; -const instanceName = '$default_instance'; describe('AmplitudeIntegrationPlugin', () => { let connector: AnalyticsConnector; @@ -21,62 +20,44 @@ describe('AmplitudeIntegrationPlugin', () => { connector = AnalyticsConnector.getInstance('$default_instance'); }); describe('constructor', () => { - test('user loaded from legacy cookie, setup undefined, connector identity set', () => { + test('user loaded from legacy cookie, connector identity set', () => { const cookieValue = `device.${btoa('user')}........`; safeGlobal.document.cookie = `amp_123456=${cookieValue}`; - const integration = new AmplitudeIntegrationPlugin( - apiKey, - instanceName, - connector, - 10000, - ); - expect(integration.setup).toBeUndefined(); + new AmplitudeIntegrationPlugin(apiKey, connector, 10000); expect(connector.identityStore.getIdentity()).toEqual({ userId: 'user', deviceId: 'device', userProperties: {}, }); }); - test('user loaded from cookie, setup undefined, connector identity set', () => { + test('user loaded from cookie, connector identity set', () => { const state: AmplitudeState = { userId: 'user', deviceId: 'device', }; const cookieValue = btoa(encodeURIComponent(JSON.stringify(state))); safeGlobal.document.cookie = `AMP_1234567890=${cookieValue}`; - const integration = new AmplitudeIntegrationPlugin( - apiKey, - instanceName, - connector, - 10000, - ); - expect(integration.setup).toBeUndefined(); + new AmplitudeIntegrationPlugin(apiKey, connector, 10000); expect(connector.identityStore.getIdentity()).toEqual({ userId: 'user', deviceId: 'device', userProperties: {}, }); }); - test('user loaded from local storage, setup undefined, connector identity set', () => { + test('user loaded from local storage, connector identity set', () => { const state: AmplitudeState = { userId: 'user', deviceId: 'device', }; safeGlobal.localStorage.setItem('AMP_1234567890', JSON.stringify(state)); - const integration = new AmplitudeIntegrationPlugin( - apiKey, - instanceName, - connector, - 10000, - ); - expect(integration.setup).toBeUndefined(); + new AmplitudeIntegrationPlugin(apiKey, connector, 10000); expect(connector.identityStore.getIdentity()).toEqual({ userId: 'user', deviceId: 'device', userProperties: {}, }); }); - test('user loaded from session storage, setup undefined, connector identity set', () => { + test('user loaded from session storage, connector identity set', () => { const state: AmplitudeState = { userId: 'user', deviceId: 'device', @@ -85,13 +66,7 @@ describe('AmplitudeIntegrationPlugin', () => { 'AMP_1234567890', JSON.stringify(state), ); - const integration = new AmplitudeIntegrationPlugin( - apiKey, - instanceName, - connector, - 10000, - ); - expect(integration.setup).toBeUndefined(); + new AmplitudeIntegrationPlugin(apiKey, connector, 10000); expect(connector.identityStore.getIdentity()).toEqual({ userId: 'user', deviceId: 'device', @@ -99,13 +74,7 @@ describe('AmplitudeIntegrationPlugin', () => { }); }); test('user not loaded, setup defined, connector identity empty', () => { - const integration = new AmplitudeIntegrationPlugin( - apiKey, - instanceName, - connector, - 10000, - ); - expect(integration.setup).toBeDefined(); + new AmplitudeIntegrationPlugin(apiKey, connector, 10000); expect(connector.identityStore.getIdentity()).toEqual({ userProperties: {}, }); @@ -115,7 +84,6 @@ describe('AmplitudeIntegrationPlugin', () => { test('setup times out', async () => { const integration = new AmplitudeIntegrationPlugin( apiKey, - instanceName, connector, 100, ); @@ -132,7 +100,6 @@ describe('AmplitudeIntegrationPlugin', () => { test('setup resolves when connector identity set before', async () => { const integration = new AmplitudeIntegrationPlugin( apiKey, - instanceName, connector, 10000, ); @@ -148,7 +115,6 @@ describe('AmplitudeIntegrationPlugin', () => { test('setup resolves when connector identity set after', async () => { const integration = new AmplitudeIntegrationPlugin( apiKey, - instanceName, connector, 10000, ); @@ -169,7 +135,6 @@ describe('AmplitudeIntegrationPlugin', () => { test('returns empty user', () => { const integration = new AmplitudeIntegrationPlugin( apiKey, - instanceName, connector, 10000, ); @@ -178,7 +143,6 @@ describe('AmplitudeIntegrationPlugin', () => { test('returns expected properties from connector', () => { const integration = new AmplitudeIntegrationPlugin( apiKey, - instanceName, connector, 10000, ); @@ -200,7 +164,6 @@ describe('AmplitudeIntegrationPlugin', () => { test('event bridge receiver not set, returns false', () => { const integration = new AmplitudeIntegrationPlugin( apiKey, - instanceName, connector, 10000, ); @@ -217,7 +180,6 @@ describe('AmplitudeIntegrationPlugin', () => { test('event bridge receiver set, calls connector event bridge, returns true', () => { const integration = new AmplitudeIntegrationPlugin( apiKey, - instanceName, connector, 10000, ); From b3cf5a20490eb96d8dd03ac7f95d690b2e1fa0b4 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Sun, 22 Sep 2024 10:46:32 -0700 Subject: [PATCH 11/24] set experimentIntegration as plugin on init --- packages/experiment-browser/src/index.ts | 7 +++++++ .../src/integration/amplitude.ts | 3 +++ packages/experiment-tag/src/experiment.ts | 14 +++++++++++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/experiment-browser/src/index.ts b/packages/experiment-browser/src/index.ts index 74cab0f5..958502d8 100644 --- a/packages/experiment-browser/src/index.ts +++ b/packages/experiment-browser/src/index.ts @@ -10,6 +10,7 @@ export { AmplitudeUserProvider, AmplitudeAnalyticsProvider, } from './providers/amplitude'; +export { AmplitudeIntegrationPlugin } from './integration/amplitude'; export { Experiment } from './factory'; export { StubExperimentClient } from './stubClient'; export { ExperimentClient } from './experimentClient'; @@ -23,3 +24,9 @@ export { Source } from './types/source'; export { ExperimentUser } from './types/user'; export { Variant, Variants } from './types/variant'; export { Exposure, ExposureTrackingProvider } from './types/exposure'; +export { + ExperimentPlugin, + IntegrationPlugin, + ExperimentPluginType, + ExperimentEvent, +} from './types/plugin'; diff --git a/packages/experiment-browser/src/integration/amplitude.ts b/packages/experiment-browser/src/integration/amplitude.ts index 3c6cb722..31784e53 100644 --- a/packages/experiment-browser/src/integration/amplitude.ts +++ b/packages/experiment-browser/src/integration/amplitude.ts @@ -52,6 +52,9 @@ export class AmplitudeIntegrationPlugin implements IntegrationPlugin { this.contextProvider = connector.applicationContextProvider; this.timeoutMillis = timeoutMillis; this.loadPersistedState(); + if (timeoutMillis <= 0) { + this.setup = undefined; + } } async setup?(config?: ExperimentConfig, client?: Client) { diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index f805f24c..5e1fc6b5 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -1,3 +1,4 @@ +import { AnalyticsConnector } from '@amplitude/analytics-connector'; import { EvaluationFlag, EvaluationSegment, @@ -9,6 +10,7 @@ import { ExperimentUser, Variant, Variants, + AmplitudeIntegrationPlugin, } from '@amplitude/experiment-js-client'; import mutate, { MutationController } from 'dom-mutator'; @@ -92,12 +94,22 @@ export const initializeExperiment = (apiKey: string, initialFlags: string) => { initialFlags = JSON.stringify(parsedFlags); } - globalScope.experiment = Experiment.initializeWithAmplitudeAnalytics(apiKey, { + globalScope.experiment = Experiment.initialize(apiKey, { debug: true, fetchOnStart: false, initialFlags: initialFlags, }); + // If no integration has been set, use an amplitude integration. + if (!globalScope.experimentIntegration) { + const connector = AnalyticsConnector.getInstance('$default_instance'); + globalScope.experimentIntegration = new AmplitudeIntegrationPlugin( + apiKey, + connector, + 0, + ); + } + globalScope.experiment.addPlugin(globalScope.experimentIntegration); globalScope.experiment.setUser(user); const variants = globalScope.experiment.all(); From 4ef74dc7e4e2bbff926a5acb8fcfa7dd736ea0a8 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Thu, 3 Oct 2024 08:31:33 -0700 Subject: [PATCH 12/24] chore: add version to tag file --- packages/experiment-tag/rollup.config.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/experiment-tag/rollup.config.js b/packages/experiment-tag/rollup.config.js index 4b400231..8fc99f82 100644 --- a/packages/experiment-tag/rollup.config.js +++ b/packages/experiment-tag/rollup.config.js @@ -10,6 +10,8 @@ import terser from '@rollup/plugin-terser'; import typescript from '@rollup/plugin-typescript'; import analyze from 'rollup-plugin-analyzer'; +import * as packageJson from './package.json'; + const getCommonBrowserConfig = (target) => ({ input: 'src/script.ts', treeshake: { @@ -51,6 +53,7 @@ const getOutputConfig = (outputOptions) => ({ output: { dir: 'dist', name: 'Experiment-Tag', + banner: `/* ${packageJson.name} v${packageJson.version} */`, ...outputOptions, }, }); @@ -66,7 +69,13 @@ const configs = [ }), plugins: [ ...getCommonBrowserConfig('es5').plugins, - terser(), // Apply terser plugin for minification + terser({ + format: { + // Don't remove semver comment + comments: + /@amplitude\/.* v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/, + }, + }), // Apply terser plugin for minification ], external: [], }, From ee6b09386f8de111ed36fac6d702b1eb94320ed7 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Fri, 4 Oct 2024 21:01:45 -0700 Subject: [PATCH 13/24] fix: dont track exposure if trackExposure metadata is false --- .../src/experimentClient.ts | 6 ++ .../experiment-browser/test/client.test.ts | 69 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/packages/experiment-browser/src/experimentClient.ts b/packages/experiment-browser/src/experimentClient.ts index afe9f984..f8276008 100644 --- a/packages/experiment-browser/src/experimentClient.ts +++ b/packages/experiment-browser/src/experimentClient.ts @@ -797,6 +797,12 @@ export class ExperimentClient implements Client { } private exposureInternal(key: string, sourceVariant: SourceVariant): void { + // Variant metadata may disable exposure tracking remotely. + const trackExposure = + (sourceVariant.variant?.metadata?.trackExposure as boolean) ?? true; + if (!trackExposure) { + return; + } this.legacyExposureInternal( key, sourceVariant.variant, diff --git a/packages/experiment-browser/test/client.test.ts b/packages/experiment-browser/test/client.test.ts index 08a997c5..4e7a81f8 100644 --- a/packages/experiment-browser/test/client.test.ts +++ b/packages/experiment-browser/test/client.test.ts @@ -1250,3 +1250,72 @@ describe('integration plugin', () => { expect(pluginExposure.eventProperties['variant']).toEqual(variant.value); }); }); + +describe('trackExposure variant metadata', () => { + test('undefined, exposure tracked', () => { + let providerExposure: Exposure; + const client = new ExperimentClient(API_KEY, { + source: Source.InitialVariants, + initialVariants: { + flag: { + key: 'on', + value: 'on', + }, + }, + exposureTrackingProvider: { + track: (e) => { + providerExposure = e; + }, + }, + }); + client.exposure('flag'); + expect(providerExposure).toEqual({ + variant: 'on', + flag_key: 'flag', + }); + }); + test('true, exposure tracked', () => { + let providerExposure: Exposure; + const client = new ExperimentClient(API_KEY, { + source: Source.InitialVariants, + initialVariants: { + flag: { + key: 'on', + value: 'on', + metadata: { trackExposure: true }, + }, + }, + exposureTrackingProvider: { + track: (e) => { + providerExposure = e; + }, + }, + }); + client.exposure('flag'); + expect(providerExposure).toEqual({ + variant: 'on', + flag_key: 'flag', + metadata: { trackExposure: true }, + }); + }); + test('false, exposure not tracked', () => { + let providerExposure: Exposure; + const client = new ExperimentClient(API_KEY, { + source: Source.InitialVariants, + initialVariants: { + flag: { + key: 'on', + value: 'on', + metadata: { trackExposure: false }, + }, + }, + exposureTrackingProvider: { + track: (e) => { + providerExposure = e; + }, + }, + }); + client.exposure('flag'); + expect(providerExposure).toBeUndefined(); + }); +}); From d6892f2815dda9b0cafea07b8ee6c07944090900 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Sat, 5 Oct 2024 12:50:16 -0700 Subject: [PATCH 14/24] fix: force integration type on global integration --- packages/experiment-tag/src/experiment.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 95ba4c30..f58fabc2 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -109,6 +109,7 @@ export const initializeExperiment = (apiKey: string, initialFlags: string) => { 0, ); } + globalScope.experimentIntegration.type = 'integration'; globalScope.webExperiment.addPlugin(globalScope.experimentIntegration); globalScope.webExperiment.setUser(user); From 9a3f60b07e3b64c07817b20ba7add1af296488e3 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Mon, 7 Oct 2024 17:20:46 -0700 Subject: [PATCH 15/24] fix: only allow one web exp instance --- packages/experiment-tag/src/experiment.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index f58fabc2..546d7f05 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -29,13 +29,16 @@ const appliedMutations: MutationController[] = []; let previousUrl: string | undefined = undefined; export const initializeExperiment = (apiKey: string, initialFlags: string) => { - WindowMessenger.setup(); - const experimentStorageName = `EXP_${apiKey.slice(0, 10)}`; const globalScope = getGlobalScope(); + if (globalScope?.webExperiment) { + return; + } + WindowMessenger.setup(); if (!isLocalStorageAvailable() || !globalScope) { return; } + const experimentStorageName = `EXP_${apiKey.slice(0, 10)}`; let user: ExperimentUser; try { user = JSON.parse( From 1763ad37f09435c7cf7133b165aec5a6fd27f6c2 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Wed, 9 Oct 2024 13:46:15 -0700 Subject: [PATCH 16/24] fix: support feat/web exp instances w/ same key & ua target --- packages/experiment-browser/src/factory.ts | 7 ++++++- .../experiment-browser/src/providers/default.ts | 7 ++++--- packages/experiment-browser/src/types/user.ts | 5 +++++ .../test/defaultUserProvider.test.ts | 4 ++++ packages/experiment-browser/test/factory.test.ts | 16 ++++++++++++++++ packages/experiment-tag/src/experiment.ts | 11 +++++------ 6 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/experiment-browser/src/factory.ts b/packages/experiment-browser/src/factory.ts index 15170497..5904901a 100644 --- a/packages/experiment-browser/src/factory.ts +++ b/packages/experiment-browser/src/factory.ts @@ -25,7 +25,12 @@ const initialize = ( // Store instances by appending the instance name and api key. Allows for // initializing multiple default instances for different api keys. const instanceName = getInstanceName(config); - const instanceKey = `${instanceName}.${apiKey}`; + // The internal instance name prefix is used by web experiment to differentiate + // web and feature experiment sdks which use the same api key. + const internalInstanceNameSuffix = config?.['internalInstanceNameSuffix']; + const instanceKey = internalInstanceNameSuffix + ? `${instanceName}.${apiKey}.${internalInstanceNameSuffix}` + : `${instanceName}.${apiKey}`; if (!instances[instanceKey]) { config = { ...config, diff --git a/packages/experiment-browser/src/providers/default.ts b/packages/experiment-browser/src/providers/default.ts index 25fb3344..1d5e711e 100644 --- a/packages/experiment-browser/src/providers/default.ts +++ b/packages/experiment-browser/src/providers/default.ts @@ -8,11 +8,11 @@ import { ExperimentUser } from '../types/user'; export class DefaultUserProvider implements ExperimentUserProvider { globalScope = getGlobalScope(); - private readonly ua = new UAParser( + private readonly userAgent: string = typeof this.globalScope?.navigator !== 'undefined' ? this.globalScope?.navigator.userAgent - : null, - ).getResult(); + : undefined; + private readonly ua = new UAParser(this.userAgent).getResult(); private readonly localStorage = new LocalStorage(); private readonly sessionStorage = new SessionStorage(); private readonly storageKey: string; @@ -40,6 +40,7 @@ export class DefaultUserProvider implements ExperimentUserProvider { landing_url: this.getLandingUrl(), first_seen: this.getFirstSeen(), url_param: this.getUrlParam(), + user_agent: this.userAgent, ...user, }; } diff --git a/packages/experiment-browser/src/types/user.ts b/packages/experiment-browser/src/types/user.ts index 7e208724..7cb08a46 100644 --- a/packages/experiment-browser/src/types/user.ts +++ b/packages/experiment-browser/src/types/user.ts @@ -121,6 +121,11 @@ export type ExperimentUser = { */ url_param?: Record; + /** + * The user agent string. + */ + user_agent?: string; + /** * Custom user properties */ diff --git a/packages/experiment-browser/test/defaultUserProvider.test.ts b/packages/experiment-browser/test/defaultUserProvider.test.ts index efcf5ec1..b380ceff 100644 --- a/packages/experiment-browser/test/defaultUserProvider.test.ts +++ b/packages/experiment-browser/test/defaultUserProvider.test.ts @@ -20,6 +20,7 @@ describe('DefaultUserProvider', () => { c1: 'v1', c2: 'v2', }, + user_agent: 'Googlebot', url_param: { p1: ['p1v1', 'p1v2'], p2: ['p2v1', 'p2v2'], p3: 'p3v1' }, }; let mockLocalStorage; @@ -151,6 +152,9 @@ describe('DefaultUserProvider', () => { }); const mockProvider = (provider: DefaultUserProvider): DefaultUserProvider => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + provider['userAgent'] = 'Googlebot'; provider['getLanguage'] = () => 'en-US'; provider['getBrowser'] = () => 'WebKit'; provider['getOs'] = () => 'WebKit 537'; diff --git a/packages/experiment-browser/test/factory.test.ts b/packages/experiment-browser/test/factory.test.ts index 4e44e497..8563f1be 100644 --- a/packages/experiment-browser/test/factory.test.ts +++ b/packages/experiment-browser/test/factory.test.ts @@ -38,3 +38,19 @@ test('Experiment.initialize, custom user provider wrapped correctly', async () = }); expect(client1.getUserProvider()).not.toStrictEqual(customUserProvider); }); + +test('Experiment.initialize, internal instance name suffix different clients', async () => { + const client1 = Experiment.initialize(API_KEY, { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + internalInstanceNameSuffix: 'test1', + debug: false, + }); + const client2 = Experiment.initialize(API_KEY, { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + internalInstanceNameSuffix: 'test2', + debug: true, + }); + expect(client2).not.toBe(client1); +}); diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 546d7f05..e74ca679 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -29,16 +29,13 @@ const appliedMutations: MutationController[] = []; let previousUrl: string | undefined = undefined; export const initializeExperiment = (apiKey: string, initialFlags: string) => { - const globalScope = getGlobalScope(); - if (globalScope?.webExperiment) { - return; - } WindowMessenger.setup(); + const experimentStorageName = `EXP_${apiKey.slice(0, 10)}`; + const globalScope = getGlobalScope(); if (!isLocalStorageAvailable() || !globalScope) { return; } - const experimentStorageName = `EXP_${apiKey.slice(0, 10)}`; let user: ExperimentUser; try { user = JSON.parse( @@ -98,7 +95,9 @@ export const initializeExperiment = (apiKey: string, initialFlags: string) => { } globalScope.webExperiment = Experiment.initialize(apiKey, { - debug: true, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + internalInstanceNameSuffix: 'web', fetchOnStart: false, initialFlags: initialFlags, }); From 4d4afd4fb51cb88fdc3a87b9a587af9c37e59dca Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Wed, 9 Oct 2024 13:53:04 -0700 Subject: [PATCH 17/24] chore: add experiment segment plugin (#128) --- .../src/experimentClient.ts | 4 +- packages/plugin-segment/jest.config.js | 18 ++ packages/plugin-segment/package.json | 41 +++++ packages/plugin-segment/rollup.config.js | 112 ++++++++++++ packages/plugin-segment/src/global.ts | 6 + packages/plugin-segment/src/index.ts | 3 + packages/plugin-segment/src/plugin.ts | 57 ++++++ packages/plugin-segment/src/snippet.ts | 47 +++++ packages/plugin-segment/src/types/plugin.ts | 25 +++ packages/plugin-segment/test/plugin.test.ts | 164 ++++++++++++++++++ packages/plugin-segment/tsconfig.json | 24 +++ packages/plugin-segment/tsconfig.test.json | 13 ++ yarn.lock | 137 ++++++++++++++- 13 files changed, 647 insertions(+), 4 deletions(-) create mode 100644 packages/plugin-segment/jest.config.js create mode 100644 packages/plugin-segment/package.json create mode 100644 packages/plugin-segment/rollup.config.js create mode 100644 packages/plugin-segment/src/global.ts create mode 100644 packages/plugin-segment/src/index.ts create mode 100644 packages/plugin-segment/src/plugin.ts create mode 100644 packages/plugin-segment/src/snippet.ts create mode 100644 packages/plugin-segment/src/types/plugin.ts create mode 100644 packages/plugin-segment/test/plugin.test.ts create mode 100644 packages/plugin-segment/tsconfig.json create mode 100644 packages/plugin-segment/tsconfig.test.json diff --git a/packages/experiment-browser/src/experimentClient.ts b/packages/experiment-browser/src/experimentClient.ts index f8276008..e4fd5bc4 100644 --- a/packages/experiment-browser/src/experimentClient.ts +++ b/packages/experiment-browser/src/experimentClient.ts @@ -254,7 +254,9 @@ export class ExperimentClient implements Client { options, ); } catch (e) { - console.error(e); + if (this.config.debug) { + console.error(e); + } } return this; } diff --git a/packages/plugin-segment/jest.config.js b/packages/plugin-segment/jest.config.js new file mode 100644 index 00000000..cadd7714 --- /dev/null +++ b/packages/plugin-segment/jest.config.js @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { pathsToModuleNameMapper } = require('ts-jest'); + +const package = require('./package'); +const { compilerOptions } = require('./tsconfig.test.json'); + +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + displayName: package.name, + rootDir: '.', + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { + prefix: '/', + }), + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: './tsconfig.test.json' }], + }, +}; diff --git a/packages/plugin-segment/package.json b/packages/plugin-segment/package.json new file mode 100644 index 00000000..66716b77 --- /dev/null +++ b/packages/plugin-segment/package.json @@ -0,0 +1,41 @@ +{ + "name": "@amplitude/experiment-plugin-segment", + "version": "0.1.0", + "private": true, + "description": "Experiment integration for segment analytics", + "author": "Amplitude", + "homepage": "https://github.com/amplitude/experiment-js-client", + "license": "MIT", + "main": "dist/experiment-plugin-segment.umd.js", + "module": "dist/experiment-plugin-segment.esm.js", + "es2015": "dist/experiment-plugin-segment.es2015.js", + "types": "dist/types/src/index.d.ts", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/amplitude/experiment-js-client.git", + "directory": "packages/plugin-segment" + }, + "scripts": { + "build": "rm -rf dist && rollup -c", + "clean": "rimraf node_modules dist", + "lint": "eslint . --ignore-path ../../.eslintignore && prettier -c . --ignore-path ../../.prettierignore", + "test": "jest", + "prepublish": "yarn build" + }, + "bugs": { + "url": "https://github.com/amplitude/experiment-js-client/issues" + }, + "dependencies": { + "@amplitude/experiment-js-client": "^1.11.0", + "@segment/analytics-next": "^1.73.0" + }, + "devDependencies": { + "@rollup/plugin-terser": "^0.4.4" + }, + "files": [ + "dist" + ] +} diff --git a/packages/plugin-segment/rollup.config.js b/packages/plugin-segment/rollup.config.js new file mode 100644 index 00000000..4ec1c0b7 --- /dev/null +++ b/packages/plugin-segment/rollup.config.js @@ -0,0 +1,112 @@ +import { resolve as pathResolve } from 'path'; + +import babel from '@rollup/plugin-babel'; +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import resolve from '@rollup/plugin-node-resolve'; +import replace from '@rollup/plugin-replace'; +import terser from '@rollup/plugin-terser'; +import typescript from '@rollup/plugin-typescript'; +import analyze from 'rollup-plugin-analyzer'; + +import * as packageJson from './package.json'; +import tsConfig from './tsconfig.json'; + +const getCommonBrowserConfig = (target) => ({ + input: 'src/index.ts', + treeshake: { + moduleSideEffects: 'no-external', + }, + plugins: [ + replace({ + preventAssignment: true, + BUILD_BROWSER: true, + }), + resolve(), + json(), + commonjs(), + typescript({ + ...(target === 'es2015' ? { target: 'es2015' } : {}), + declaration: true, + declarationDir: 'dist/types', + include: tsConfig.include, + rootDir: '.', + }), + babel({ + configFile: + target === 'es2015' + ? pathResolve(__dirname, '../..', 'babel.es2015.config.js') + : undefined, + babelHelpers: 'bundled', + exclude: ['node_modules/**'], + }), + analyze({ + summaryOnly: true, + }), + ], +}); + +const getOutputConfig = (outputOptions) => ({ + output: { + dir: 'dist', + name: 'Experiment', + ...outputOptions, + }, +}); + +const configs = [ + // minified build + { + ...getCommonBrowserConfig('es5'), + ...getOutputConfig({ + entryFileNames: 'experiment-plugin-segment.min.js', + exports: 'named', + format: 'umd', + banner: `/* ${packageJson.name} v${packageJson.version} */`, + }), + plugins: [ + ...getCommonBrowserConfig('es5').plugins, + terser({ + format: { + // Don't remove semver comment + comments: + /@amplitude\/.* v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/, + }, + }), // Apply terser plugin for minification + ], + external: [], + }, + + // legacy build for field "main" - ie8, umd, es5 syntax + { + ...getCommonBrowserConfig('es5'), + ...getOutputConfig({ + entryFileNames: 'experiment-plugin-segment.umd.js', + exports: 'named', + format: 'umd', + }), + external: [], + }, + + // tree shakable build for field "module" - ie8, esm, es5 syntax + { + ...getCommonBrowserConfig('es5'), + ...getOutputConfig({ + entryFileNames: 'experiment-plugin-segment.esm.js', + format: 'esm', + }), + external: [], + }, + + // modern build for field "es2015" - not ie, esm, es2015 syntax + { + ...getCommonBrowserConfig('es2015'), + ...getOutputConfig({ + entryFileNames: 'experiment-plugin-segment.es2015.js', + format: 'esm', + }), + external: [], + }, +]; + +export default configs; diff --git a/packages/plugin-segment/src/global.ts b/packages/plugin-segment/src/global.ts new file mode 100644 index 00000000..b6784c3f --- /dev/null +++ b/packages/plugin-segment/src/global.ts @@ -0,0 +1,6 @@ +export const safeGlobal = + typeof globalThis !== 'undefined' + ? globalThis + : typeof global !== 'undefined' + ? global + : self; diff --git a/packages/plugin-segment/src/index.ts b/packages/plugin-segment/src/index.ts new file mode 100644 index 00000000..db7db6e1 --- /dev/null +++ b/packages/plugin-segment/src/index.ts @@ -0,0 +1,3 @@ +export { segmentIntegrationPlugin } from './plugin'; +export { segmentIntegrationPlugin as plugin } from './plugin'; +export { SegmentIntegrationPlugin, Options } from './types/plugin'; diff --git a/packages/plugin-segment/src/plugin.ts b/packages/plugin-segment/src/plugin.ts new file mode 100644 index 00000000..1493665a --- /dev/null +++ b/packages/plugin-segment/src/plugin.ts @@ -0,0 +1,57 @@ +import type { + ExperimentEvent, + ExperimentUser, + IntegrationPlugin, +} from '@amplitude/experiment-js-client'; + +import { safeGlobal } from './global'; +import { snippetInstance } from './snippet'; +import { Options, SegmentIntegrationPlugin } from './types/plugin'; + +export const segmentIntegrationPlugin: SegmentIntegrationPlugin = ( + options: Options = {}, +) => { + const getInstance = () => { + return options.instance || snippetInstance(options.instanceKey); + }; + getInstance(); + const plugin: IntegrationPlugin = { + name: '@amplitude/experiment-plugin-segment', + type: 'integration', + setup(): Promise { + const instance = getInstance(); + return new Promise((resolve) => instance.ready(() => resolve())); + }, + getUser(): ExperimentUser { + const instance = getInstance(); + if (instance.initialized) { + return { + user_id: instance.user().id(), + device_id: instance.user().anonymousId(), + user_properties: instance.user().traits(), + }; + } + const get = (key: string) => { + return JSON.parse(safeGlobal.localStorage.getItem(key)) || undefined; + }; + return { + user_id: get('ajs_user_id'), + device_id: get('ajs_anonymous_id'), + user_properties: get('ajs_user_traits'), + }; + }, + track(event: ExperimentEvent): boolean { + const instance = getInstance(); + if (!instance.initialized) return false; + instance.track(event.eventType, event.eventProperties); + return true; + }, + }; + if (options.skipSetup) { + plugin.setup = undefined; + } + + return plugin; +}; + +safeGlobal.experimentIntegration = segmentIntegrationPlugin(); diff --git a/packages/plugin-segment/src/snippet.ts b/packages/plugin-segment/src/snippet.ts new file mode 100644 index 00000000..f5859449 --- /dev/null +++ b/packages/plugin-segment/src/snippet.ts @@ -0,0 +1,47 @@ +import { safeGlobal } from './global'; + +/** + * Copied and modified from https://github.com/segmentio/snippet/blob/master/template/snippet.js + * + * This function will set up proxy stubs for functions used by the segment plugin + * + * @param instanceKey the key for the analytics instance on the global object. + */ +export const snippetInstance = ( + instanceKey: string | undefined = undefined, +) => { + // define the key where the global analytics object will be accessible + // customers can safely set this to be something else if need be + const key = instanceKey || 'analytics'; + + // Create a queue, but don't obliterate an existing one! + const analytics = (safeGlobal[key] = safeGlobal[key] || []); + + // If the real analytics.js is already on the page return. + if (analytics.initialize) { + return analytics; + } + const fn = 'ready'; + if (analytics[fn]) { + return analytics; + } + const factory = function (fn) { + return function () { + if (safeGlobal[key].initialized) { + // Sometimes users assigned analytics to a variable before analytics is + // done loading, resulting in a stale reference. If so, proxy any calls + // to the 'real' analytics instance. + // eslint-disable-next-line prefer-spread,prefer-rest-params + return safeGlobal[key][fn].apply(safeGlobal[key], arguments); + } + // eslint-disable-next-line prefer-rest-params + const args = Array.prototype.slice.call(arguments); + args.unshift(fn); + analytics.push(args); + return analytics; + }; + }; + // Use the predefined factory, or our own factory to stub the function. + analytics[fn] = (analytics.factory || factory)(fn); + return analytics; +}; diff --git a/packages/plugin-segment/src/types/plugin.ts b/packages/plugin-segment/src/types/plugin.ts new file mode 100644 index 00000000..803da7b8 --- /dev/null +++ b/packages/plugin-segment/src/types/plugin.ts @@ -0,0 +1,25 @@ +import { IntegrationPlugin } from '@amplitude/experiment-js-client'; +import { Analytics } from '@segment/analytics-next'; + +export interface Options { + /** + * An existing segment analytics instance. This instance will be used instead + * of the instance on the window defined by the instanceKey. + */ + instance?: Analytics; + /** + * The key of the field on the window that holds the segment analytics + * instance when the script is loaded via the script loader. + * + * Defaults to "analytics". + */ + instanceKey?: string; + /** + * Skip waiting for the segment SDK to load and be ready. + */ + skipSetup?: boolean; +} + +export interface SegmentIntegrationPlugin { + (options?: Options): IntegrationPlugin; +} diff --git a/packages/plugin-segment/test/plugin.test.ts b/packages/plugin-segment/test/plugin.test.ts new file mode 100644 index 00000000..bda679dc --- /dev/null +++ b/packages/plugin-segment/test/plugin.test.ts @@ -0,0 +1,164 @@ +import { safeGlobal } from '@amplitude/experiment-core'; +import { ExperimentEvent } from '@amplitude/experiment-js-client'; +import { Analytics } from '@segment/analytics-next'; +import { segmentIntegrationPlugin } from 'src/plugin'; +import { snippetInstance } from 'src/snippet'; + +const anonymousId = 'anon'; +const userId = 'user'; +const traits = { k: 'v' }; + +const impression: ExperimentEvent = { + eventType: '$impression', + eventProperties: { flag_key: 'flag-key', variant: 'on' }, +}; + +const mockAnalytics = (): Analytics => + ({ + initialized: true, + initialize: () => Promise.resolve({} as Analytics), + user: () => ({ + id: () => userId, + anonymousId: () => anonymousId, + traits: () => traits, + }), + track: () => Promise.resolve(), + } as unknown as Analytics); + +let instance: Analytics; + +describe('SegmentIntegrationPlugin', () => { + beforeEach(async () => { + safeGlobal.analytics = undefined; + safeGlobal.asdf = undefined; + safeGlobal.localStorage.clear(); + instance = mockAnalytics(); + }); + + test('name', () => { + const plugin = segmentIntegrationPlugin(); + expect(plugin.name).toEqual('@amplitude/experiment-plugin-segment'); + }); + test('type', () => { + const plugin = segmentIntegrationPlugin(); + expect(plugin.type).toEqual('integration'); + }); + test('sets analytics global if not already defined', () => { + segmentIntegrationPlugin(); + expect(safeGlobal.analytics).toBeDefined(); + const expected = snippetInstance(); + expect(safeGlobal.analytics).toEqual(expected); + expect(JSON.stringify(safeGlobal.analytics)).toEqual(JSON.stringify([])); + }); + test('does not set analytics global if not already defined', () => { + safeGlobal.analytics = ['test']; + segmentIntegrationPlugin(); + expect(safeGlobal.analytics).toBeDefined(); + const expected = snippetInstance(); + expect(safeGlobal.analytics).toEqual(expected); + expect(JSON.stringify(safeGlobal.analytics)).toEqual( + JSON.stringify(['test']), + ); + }); + test('with instance key, sets analytics global if not already defined', () => { + segmentIntegrationPlugin({ instanceKey: 'asdf' }); + expect(safeGlobal.analytics).toBeUndefined(); + expect(safeGlobal.asdf).toBeDefined(); + const expected = snippetInstance('asdf'); + expect(safeGlobal.asdf).toEqual(expected); + expect(JSON.stringify(safeGlobal.asdf)).toEqual(JSON.stringify([])); + }); + test('with instance key, does not set analytics global if not already defined', () => { + safeGlobal.asdf = ['test']; + segmentIntegrationPlugin({ instanceKey: 'asdf' }); + expect(safeGlobal.analytics).toBeUndefined(); + expect(safeGlobal.asdf).toBeDefined(); + const expected = snippetInstance('asdf'); + expect(safeGlobal.asdf).toEqual(expected); + expect(JSON.stringify(safeGlobal.asdf)).toEqual(JSON.stringify(['test'])); + }); + test('with instance config, does not set instance', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + segmentIntegrationPlugin({ instance }); + expect(safeGlobal.analytics).toBeUndefined(); + }); + describe('setup', () => { + test('no options, setup function exists', () => { + const plugin = segmentIntegrationPlugin(); + expect(plugin.setup).toBeDefined(); + }); + test('setup config false, setup function undefined', () => { + const plugin = segmentIntegrationPlugin({ skipSetup: true }); + expect(plugin.setup).toBeUndefined(); + expect(safeGlobal.analytics).toBeDefined(); + }); + }); + describe('getUser', () => { + test('returns user from local storage', () => { + const plugin = segmentIntegrationPlugin(); + expect(plugin.getUser()).toEqual({}); + safeGlobal.localStorage.setItem( + 'ajs_anonymous_id', + JSON.stringify(anonymousId), + ); + safeGlobal.localStorage.setItem('ajs_user_id', JSON.stringify(userId)); + safeGlobal.localStorage.setItem( + 'ajs_user_traits', + JSON.stringify(traits), + ); + expect(plugin.getUser()).toEqual({ + user_id: userId, + device_id: anonymousId, + user_properties: traits, + }); + safeGlobal.localStorage.setItem('ajs_user_id', JSON.stringify(null)); + expect(plugin.getUser()).toEqual({ + device_id: anonymousId, + user_properties: traits, + }); + }); + test('with instance, returns user from instance', () => { + const plugin = segmentIntegrationPlugin({ instance }); + expect(plugin.getUser()).toEqual({ + user_id: userId, + device_id: anonymousId, + user_properties: traits, + }); + }); + test('with instance, not initialized, returns user from local storage', () => { + instance.initialized = false; + const plugin = segmentIntegrationPlugin({ instance }); + expect(plugin.getUser()).toEqual({}); + }); + test('without instance, initialized, returns user from instance', () => { + safeGlobal.analytics = mockAnalytics(); + const plugin = segmentIntegrationPlugin(); + expect(plugin.getUser()).toEqual({ + user_id: userId, + device_id: anonymousId, + user_properties: traits, + }); + }); + }); + describe('track', () => { + test('without instance, not initialized, returns false', () => { + const plugin = segmentIntegrationPlugin(); + expect(plugin.track(impression)).toEqual(false); + }); + test('without instance, initialized, returns true', () => { + safeGlobal.analytics = mockAnalytics(); + const plugin = segmentIntegrationPlugin(); + expect(plugin.track(impression)).toEqual(true); + }); + test('with instance, not initialized, returns false', () => { + instance.initialized = false; + const plugin = segmentIntegrationPlugin({ instance }); + expect(plugin.track(impression)).toEqual(false); + }); + test('with instance, initialized, returns false', () => { + const plugin = segmentIntegrationPlugin(); + expect(plugin.track(impression)).toEqual(false); + }); + }); +}); diff --git a/packages/plugin-segment/tsconfig.json b/packages/plugin-segment/tsconfig.json new file mode 100644 index 00000000..c694f848 --- /dev/null +++ b/packages/plugin-segment/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts", "package.json"], + "typedocOptions": { + "name": "Experiment JS Client Documentation", + "entryPoints": ["./src/index.ts"], + "categoryOrder": [ + "Core Usage", + "Configuration", + "Context Provider", + "Types" + ], + "categorizeByGroup": false, + "disableSources": true, + "excludePrivate": true, + "excludeProtected": true, + "excludeInternal": true, + "hideGenerator": true, + "includeVersion": true, + "out": "../../docs", + "readme": "none", + "theme": "minimal" + } +} diff --git a/packages/plugin-segment/tsconfig.test.json b/packages/plugin-segment/tsconfig.test.json new file mode 100644 index 00000000..a6ff84d1 --- /dev/null +++ b/packages/plugin-segment/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "rootDir": ".", + "baseUrl": ".", + "paths": { + "src/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["dist"] +} diff --git a/yarn.lock b/yarn.lock index 017d374d..de885a44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1548,6 +1548,18 @@ write-pkg "4.0.0" yargs "16.2.0" +"@lukeed/csprng@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@lukeed/csprng/-/csprng-1.1.0.tgz#1e3e4bd05c1cc7a0b2ddbd8a03f39f6e4b5e6cfe" + integrity sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA== + +"@lukeed/uuid@^2.0.0": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@lukeed/uuid/-/uuid-2.0.1.tgz#4f6c34259ee0982a455e1797d56ac27bb040fd74" + integrity sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w== + dependencies: + "@lukeed/csprng" "^1.1.0" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -2037,6 +2049,68 @@ estree-walker "^2.0.2" picomatch "^2.3.1" +"@segment/analytics-core@1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@segment/analytics-core/-/analytics-core-1.7.0.tgz#2ca9495460316a2e23df3097919e391594d9b1e3" + integrity sha512-0DHSriS/oAB/2bIgOMv3fFV9/ivp39ibdOTTf+dDOhf+vlciBv0+MHw47k/6PRobbuls27cKkKZAKc4DDC2+gw== + dependencies: + "@lukeed/uuid" "^2.0.0" + "@segment/analytics-generic-utils" "1.2.0" + dset "^3.1.4" + tslib "^2.4.1" + +"@segment/analytics-generic-utils@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@segment/analytics-generic-utils/-/analytics-generic-utils-1.2.0.tgz#9232162d6dbcd18501813fdff18035ce48fd24bf" + integrity sha512-DfnW6mW3YQOLlDQQdR89k4EqfHb0g/3XvBXkovH1FstUN93eL1kfW9CsDcVQyH3bAC5ZsFyjA/o/1Q2j0QeoWw== + dependencies: + tslib "^2.4.1" + +"@segment/analytics-next@^1.73.0": + version "1.73.0" + resolved "https://registry.yarnpkg.com/@segment/analytics-next/-/analytics-next-1.73.0.tgz#caf078d175c331e2f7a0fb6d07ef134b4d7a564a" + integrity sha512-E6MyxLy0qPAqOABUys/3HHhFQ5cStJ/XxupZsGhRAOqqaaP3fS6PFxQkoTsInE/18cMfDcmYYjfbgWr+gCc5UQ== + dependencies: + "@lukeed/uuid" "^2.0.0" + "@segment/analytics-core" "1.7.0" + "@segment/analytics-generic-utils" "1.2.0" + "@segment/analytics.js-video-plugins" "^0.2.1" + "@segment/facade" "^3.4.9" + dset "^3.1.4" + js-cookie "3.0.1" + node-fetch "^2.6.7" + tslib "^2.4.1" + unfetch "^4.1.0" + +"@segment/analytics.js-video-plugins@^0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@segment/analytics.js-video-plugins/-/analytics.js-video-plugins-0.2.1.tgz#3596fa3887dcd9df5978dc566edf4a0aea2a9b1e" + integrity sha512-lZwCyEXT4aaHBLNK433okEKdxGAuyrVmop4BpQqQSJuRz0DglPZgd9B/XjiiWs1UyOankg2aNYMN3VcS8t4eSQ== + dependencies: + unfetch "^3.1.1" + +"@segment/facade@^3.4.9": + version "3.4.10" + resolved "https://registry.yarnpkg.com/@segment/facade/-/facade-3.4.10.tgz#118fab29cf2250d3128f9b2a16d6ec76f86e3710" + integrity sha512-xVQBbB/lNvk/u8+ey0kC/+g8pT3l0gCT8O2y9Z+StMMn3KAFAQ9w8xfgef67tJybktOKKU7pQGRPolRM1i1pdA== + dependencies: + "@segment/isodate-traverse" "^1.1.1" + inherits "^2.0.4" + new-date "^1.0.3" + obj-case "0.2.1" + +"@segment/isodate-traverse@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@segment/isodate-traverse/-/isodate-traverse-1.1.1.tgz#37e1a68b5e48a841260145f1be86d342995dfc64" + integrity sha512-+G6e1SgAUkcq0EDMi+SRLfT48TNlLPF3QnSgFGVs0V9F3o3fq/woQ2rHFlW20W0yy5NnCUH0QGU3Am2rZy/E3w== + dependencies: + "@segment/isodate" "^1.0.3" + +"@segment/isodate@1.0.3", "@segment/isodate@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@segment/isodate/-/isodate-1.0.3.tgz#f44e8202d5edd277ce822785239474b2c9411d4a" + integrity sha512-BtanDuvJqnACFkeeYje7pWULVv8RgZaqKHWwGFnL/g/TH/CcZjkIVTfGDp/MAxmilYHUkrX70SqwnYSTNEaN7A== + "@sigstore/bundle@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@sigstore/bundle/-/bundle-1.0.0.tgz#2f2f4867f434760f4bc6f4b4bbccbaecd4143bc3" @@ -3603,6 +3677,11 @@ dotenv@~10.0.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81" integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== +dset@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.4.tgz#f8eaf5f023f068a036d08cd07dc9ffb7d0065248" + integrity sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA== + duplexer@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" @@ -5592,6 +5671,11 @@ js-base64@^3.7.5: resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.5.tgz#21e24cf6b886f76d6f5f165bfcd69cc55b9e3fca" integrity sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA== +js-cookie@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.1.tgz#9e39b4c6c2f56563708d7d31f6f5f21873a92414" + integrity sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -6353,6 +6437,13 @@ neo-async@^2.6.0: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +new-date@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/new-date/-/new-date-1.0.3.tgz#a5956086d3f5ed43d0b210d87a10219ccb7a2326" + integrity sha512-0fsVvQPbo2I18DT2zVHpezmeeNYV2JaJSrseiHLc17GNOxJzUdx5mvSigPu8LtIfZSij5i1wXnXFspEs2CD6hA== + dependencies: + "@segment/isodate" "1.0.3" + node-addon-api@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" @@ -6677,6 +6768,11 @@ nx@15.9.4, "nx@>=15.5.2 < 16": "@nrwl/nx-win32-arm64-msvc" "15.9.4" "@nrwl/nx-win32-x64-msvc" "15.9.4" +obj-case@0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/obj-case/-/obj-case-0.2.1.tgz#13a554d04e5ca32dfd9d566451fd2b0e11007f1a" + integrity sha512-PquYBBTy+Y6Ob/O2574XHhDtHJlV1cJHMCgW+rDRc9J5hhmRelJB3k5dTK/3cVmFVtzvAKuENeuLpoyTzMzkOg== + object-inspect@^1.12.3, object-inspect@^1.9.0: version "1.12.3" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9" @@ -7864,7 +7960,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -7923,7 +8028,14 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -8226,6 +8338,11 @@ tslib@^2.0.0, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0, tslib@^2.5.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.1.tgz#fd8c9a0ff42590b25703c0acb3de3d3f4ede0410" integrity sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig== +tslib@^2.4.1: + version "2.7.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" + integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -8373,6 +8490,11 @@ unfetch@4.1.0: resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-4.1.0.tgz#6ec2dd0de887e58a4dee83a050ded80ffc4137db" integrity sha512-crP/n3eAPUJxZXM9T80/yv0YhkTEx2K1D3h7D1AJM6fzsWZrxdyRuLN0JH/dkZh1LNH8LxCnBzoPFCPbb2iGpg== +unfetch@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-3.1.2.tgz#dc271ef77a2800768f7b459673c5604b5101ef77" + integrity sha512-L0qrK7ZeAudGiKYw6nzFjnJ2D5WHblUBwmHIqtPS6oKUd+Hcpk7/hKsSmcHsTlpd1TbTNsiRBUKRq3bHLNIqIw== + unfetch@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-4.2.0.tgz#7e21b0ef7d363d8d9af0fb929a5555f6ef97a3be" @@ -8654,7 +8776,16 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From f6cf0daf2a4afe745a6b8fedf571e720fdf1f129 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Wed, 9 Oct 2024 14:46:08 -0700 Subject: [PATCH 18/24] fix: bind tracking call to correct object --- .../experiment-browser/src/integration/manager.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/experiment-browser/src/integration/manager.ts b/packages/experiment-browser/src/integration/manager.ts index 2fbf1171..f6becbda 100644 --- a/packages/experiment-browser/src/integration/manager.ts +++ b/packages/experiment-browser/src/integration/manager.ts @@ -1,8 +1,4 @@ -import { - getGlobalScope, - isLocalStorageAvailable, - safeGlobal, -} from '@amplitude/experiment-core'; +import { getGlobalScope, isLocalStorageAvailable, safeGlobal } from '@amplitude/experiment-core'; import { Defaults, ExperimentConfig } from '../config'; import { Client } from '../types/client'; @@ -61,17 +57,17 @@ export class IntegrationManager { if (integration.setup) { this.integration.setup(this.config, this.client).then( () => { - this.queue.tracker = this.integration.track; + this.queue.tracker = this.integration.track.bind(integration); this.resolve(); }, (e) => { console.error('Integration setup failed.', e); - this.queue.tracker = this.integration.track; + this.queue.tracker = this.integration.track.bind(integration); this.resolve(); }, ); } else { - this.queue.tracker = this.integration.track; + this.queue.tracker = this.integration.track.bind(integration); this.resolve(); } } From 818cee736fdc547815f7f7473b73b3a20754b06b Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Wed, 9 Oct 2024 15:27:03 -0700 Subject: [PATCH 19/24] fix: lint --- packages/experiment-browser/src/integration/manager.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/experiment-browser/src/integration/manager.ts b/packages/experiment-browser/src/integration/manager.ts index f6becbda..c41b85d5 100644 --- a/packages/experiment-browser/src/integration/manager.ts +++ b/packages/experiment-browser/src/integration/manager.ts @@ -1,4 +1,8 @@ -import { getGlobalScope, isLocalStorageAvailable, safeGlobal } from '@amplitude/experiment-core'; +import { + getGlobalScope, + isLocalStorageAvailable, + safeGlobal, +} from '@amplitude/experiment-core'; import { Defaults, ExperimentConfig } from '../config'; import { Client } from '../types/client'; From d0c8eccfb8addce9e1eaeba55ec7d131a758face Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Wed, 9 Oct 2024 16:21:27 -0700 Subject: [PATCH 20/24] fix: dont allow double initialization --- packages/experiment-browser/src/integration/manager.ts | 3 +++ packages/experiment-tag/src/experiment.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/experiment-browser/src/integration/manager.ts b/packages/experiment-browser/src/integration/manager.ts index c41b85d5..8621b520 100644 --- a/packages/experiment-browser/src/integration/manager.ts +++ b/packages/experiment-browser/src/integration/manager.ts @@ -157,6 +157,9 @@ export class PersistentTrackingQueue { constructor(instanceName: string, maxQueueSize: number = MAX_QUEUE_SIZE) { this.storageKey = `EXP_unsent_${instanceName}`; this.maxQueueSize = maxQueueSize; + this.loadQueue(); + this.flush(); + this.storeQueue(); } push(event: ExperimentEvent): void { diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index e74ca679..ec9fa86f 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -29,13 +29,16 @@ const appliedMutations: MutationController[] = []; let previousUrl: string | undefined = undefined; export const initializeExperiment = (apiKey: string, initialFlags: string) => { - WindowMessenger.setup(); - const experimentStorageName = `EXP_${apiKey.slice(0, 10)}`; const globalScope = getGlobalScope(); - + if (globalScope?.webExperiment) { + return; + } + WindowMessenger.setup(); if (!isLocalStorageAvailable() || !globalScope) { return; } + + const experimentStorageName = `EXP_${apiKey.slice(0, 10)}`; let user: ExperimentUser; try { user = JSON.parse( From f0e7d390cfc35495f7aea327cfc5898ec3971cb5 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Wed, 9 Oct 2024 17:23:14 -0700 Subject: [PATCH 21/24] fix: poller --- .../src/integration/manager.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/experiment-browser/src/integration/manager.ts b/packages/experiment-browser/src/integration/manager.ts index 8621b520..2f4994e2 100644 --- a/packages/experiment-browser/src/integration/manager.ts +++ b/packages/experiment-browser/src/integration/manager.ts @@ -151,6 +151,7 @@ export class PersistentTrackingQueue { private readonly maxQueueSize: number; private readonly isLocalStorageAvailable = isLocalStorageAvailable(); private inMemoryQueue: ExperimentEvent[] = []; + private poller: any | undefined; tracker: ((event: ExperimentEvent) => boolean) | undefined; @@ -158,8 +159,12 @@ export class PersistentTrackingQueue { this.storageKey = `EXP_unsent_${instanceName}`; this.maxQueueSize = maxQueueSize; this.loadQueue(); - this.flush(); - this.storeQueue(); + if (this.inMemoryQueue.length > 0) { + this.poller = safeGlobal.setInterval(() => { + this.loadFlushStore(); + }, 1000); + } + this.loadFlushStore(); } push(event: ExperimentEvent): void { @@ -176,6 +181,10 @@ export class PersistentTrackingQueue { if (!this.tracker(event)) return; } this.inMemoryQueue = []; + if (this.poller) { + safeGlobal.clearInterval(this.poller); + this.poller = undefined; + } } private loadQueue(): void { @@ -199,6 +208,12 @@ export class PersistentTrackingQueue { ); } } + + private loadFlushStore(): void { + this.loadQueue(); + this.flush(); + this.storeQueue(); + } } const isSessionStorageAvailable = (): boolean => { From 28591140745d8f712f5fb3ba6f268d138ff540cb Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Thu, 10 Oct 2024 09:36:20 -0700 Subject: [PATCH 22/24] fix: test increase timeout --- .../experiment-browser/test/client.test.ts | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/packages/experiment-browser/test/client.test.ts b/packages/experiment-browser/test/client.test.ts index 4e7a81f8..235e9f53 100644 --- a/packages/experiment-browser/test/client.test.ts +++ b/packages/experiment-browser/test/client.test.ts @@ -676,26 +676,30 @@ describe('variant fallbacks', () => { expect(spy.mock.calls[0][0].variant).toBeUndefined(); }); - test('default variant returned when no other fallback is provided', async () => { - const user = {}; - const exposureTrackingProvider = new TestExposureTrackingProvider(); - const spy = jest.spyOn(exposureTrackingProvider, 'track'); - const client = new ExperimentClient(API_KEY, { - exposureTrackingProvider: exposureTrackingProvider, - source: Source.LocalStorage, - fetchOnStart: true, - }); - mockClientStorage(client); - // Start and fetch - await client.start(user); - const variant = client.variant('sdk-ci-test'); - expect(variant.key).toEqual('off'); - expect(variant.value).toBeUndefined(); - expect(variant.metadata?.default).toEqual(true); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy.mock.calls[0][0].flag_key).toEqual('sdk-ci-test'); - expect(spy.mock.calls[0][0].variant).toBeUndefined(); - }); + test( + 'default variant returned when no other fallback is provided', + async () => { + const user = {}; + const exposureTrackingProvider = new TestExposureTrackingProvider(); + const spy = jest.spyOn(exposureTrackingProvider, 'track'); + const client = new ExperimentClient(API_KEY, { + exposureTrackingProvider: exposureTrackingProvider, + source: Source.LocalStorage, + fetchOnStart: true, + }); + mockClientStorage(client); + // Start and fetch + await client.start(user); + const variant = client.variant('sdk-ci-test'); + expect(variant.key).toEqual('off'); + expect(variant.value).toBeUndefined(); + expect(variant.metadata?.default).toEqual(true); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].flag_key).toEqual('sdk-ci-test'); + expect(spy.mock.calls[0][0].variant).toBeUndefined(); + }, + 10 * 1000, + ); }); describe('initial variants source', () => { From 3e4c9cbf65e65e31731b88cb6df2310c47d95a99 Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Tue, 15 Oct 2024 13:29:59 -0700 Subject: [PATCH 23/24] fix: support custom exposrue events in metadata --- .../src/integration/manager.ts | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/experiment-browser/src/integration/manager.ts b/packages/experiment-browser/src/integration/manager.ts index b7cafa85..d8508aa9 100644 --- a/packages/experiment-browser/src/integration/manager.ts +++ b/packages/experiment-browser/src/integration/manager.ts @@ -96,20 +96,30 @@ export class IntegrationManager { */ track(exposure: Exposure): void { if (this.cache.shouldTrack(exposure)) { - if (exposure.metadata?.deliveryMethod === 'web') { - // Track impression - this.queue.push({ - eventType: '$impression', - eventProperties: exposure, - }); - } else { - // Track exposure - this.queue.push({ - eventType: '$exposure', - eventProperties: exposure, - }); - } + const event = this.getExposureEvent(exposure); + this.queue.push(event); + } + } + + private getExposureEvent(exposure: Exposure): ExperimentEvent { + let event: ExperimentEvent = { + eventType: '$exposure', + eventProperties: exposure, + }; + if (exposure.metadata?.exposureEvent) { + // Metadata specifically passes the exposure event definition + event = { + eventType: exposure.metadata?.exposureEvent as string, + eventProperties: exposure, + }; + } else if (exposure.metadata?.deliveryMethod === 'web') { + // Web experiments track impression events by default + event = { + eventType: '$impression', + eventProperties: exposure, + }; } + return event; } } From 8c5c7b0e13583af867d686fc77fcd7d8058cd44a Mon Sep 17 00:00:00 2001 From: Brian Giori Date: Tue, 15 Oct 2024 13:54:54 -0700 Subject: [PATCH 24/24] remove segment example --- .../html-app/segment-integration/README.md | 13 ----- .../html-app/segment-integration/index.html | 49 ------------------- .../html-app/segment-integration/package.json | 7 --- 3 files changed, 69 deletions(-) delete mode 100644 examples/html-app/segment-integration/README.md delete mode 100644 examples/html-app/segment-integration/index.html delete mode 100644 examples/html-app/segment-integration/package.json diff --git a/examples/html-app/segment-integration/README.md b/examples/html-app/segment-integration/README.md deleted file mode 100644 index 6ba21bdf..00000000 --- a/examples/html-app/segment-integration/README.md +++ /dev/null @@ -1,13 +0,0 @@ -This is a demo project for implementing Amplitude Experiment using Amplitude Analytics SDK integration in a basic HTML page. - -## Getting Started - -First, run the development server: - -```bash -npm start -# or -yarn start -``` - -Open [http://localhost:8080](http://localhost:8080) with your browser to see the result. diff --git a/examples/html-app/segment-integration/index.html b/examples/html-app/segment-integration/index.html deleted file mode 100644 index 6e895c11..00000000 --- a/examples/html-app/segment-integration/index.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - Experiment Example - Amplitude Integration - - - - - - - - - - -

Amplitude Experiment Browser Example - Amplitude Integration

- -

Click "Fetch" to fetch variants, then "Variant" or "All" to access variants from the SDK.

-

Open the console to view debug output from the SDK.

- - - - - - - - -
- -

-  
-
diff --git a/examples/html-app/segment-integration/package.json b/examples/html-app/segment-integration/package.json
deleted file mode 100644
index f6b51078..00000000
--- a/examples/html-app/segment-integration/package.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-  "name": "html-app",
-  "version": "1.0.0",
-  "scripts": {
-    "start": "npx http-server"
-  }
-}