Skip to content

Commit

Permalink
feat: add integration plugin; segment plugin; web exp updates (#126)
Browse files Browse the repository at this point in the history
  • Loading branch information
bgiori authored Oct 10, 2024
1 parent 9d11786 commit 58446e2
Show file tree
Hide file tree
Showing 34 changed files with 2,398 additions and 409 deletions.
17 changes: 1 addition & 16 deletions packages/analytics-connector/src/analyticsConnector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
}
52 changes: 6 additions & 46 deletions packages/analytics-connector/src/eventBridge.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
import {
getGlobalScope,
isLocalStorageAvailable,
} from '@amplitude/experiment-core';

export type AnalyticsEvent = {
eventType: string;
eventProperties?: Record<string, unknown>;
Expand All @@ -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);
Expand All @@ -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;
}
}
44 changes: 31 additions & 13 deletions packages/experiment-browser/src/experimentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -84,6 +84,7 @@ export class ExperimentClient implements Client {
flagPollerIntervalMillis,
);
private isRunning = false;
private readonly integrationManager: IntegrationManager;

// Deprecated
private analyticsProvider: SessionAnalyticsProvider | undefined;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -252,7 +254,9 @@ export class ExperimentClient implements Client {
options,
);
} catch (e) {
console.error(e);
if (this.config.debug) {
console.error(e);
}
}
return this;
}
Expand Down Expand Up @@ -677,7 +681,7 @@ export class ExperimentClient implements Client {
timeoutMillis: number,
options?: FetchOptions,
): Promise<Variants> {
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, {
Expand Down Expand Up @@ -756,28 +760,25 @@ 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,
};
}

private async addContextOrWait(
user: ExperimentUser,
ms: number,
): Promise<ExperimentUser> {
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);
}

Expand All @@ -798,6 +799,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,
Expand All @@ -823,6 +830,7 @@ export class ExperimentClient implements Client {
}
if (metadata) exposure.metadata = metadata;
this.exposureTrackingProvider?.track(exposure);
this.integrationManager.track(exposure);
}

private legacyExposureInternal(
Expand Down Expand Up @@ -855,6 +863,16 @@ export class ExperimentClient implements Client {
}
return true;
}

/**
* Add a plugin to the experiment client.
* @param plugin the plugin to add.
*/
public addPlugin(plugin: ExperimentPlugin): void {
if (plugin.type === 'integration') {
this.integrationManager.setIntegration(plugin as IntegrationPlugin);
}
}
}

type SourceVariant = {
Expand Down
63 changes: 24 additions & 39 deletions packages/experiment-browser/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ 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 { AmplitudeIntegrationPlugin } from './integration/amplitude';
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.
Expand All @@ -23,17 +24,17 @@ 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 instanceKey = `${instanceName}.${apiKey}`;
const connector = AnalyticsConnector.getInstance(instanceName);
const instanceName = getInstanceName(config);
// 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,
userProvider: new DefaultUserProvider(
connector.applicationContextProvider,
config?.userProvider,
apiKey,
),
userProvider: new DefaultUserProvider(config?.userProvider, apiKey),
};
instances[instanceKey] = new ExperimentClient(apiKey, config);
}
Expand All @@ -55,32 +56,16 @@ 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(
connector.applicationContextProvider,
new ConnectorUserProvider(connector.identityStore),
apiKey,
),
exposureTrackingProvider: new ConnectorExposureTrackingProvider(
connector.eventBridge,
),
...config,
};
instances[instanceKey] = new ExperimentClient(apiKey, config);
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,
AnalyticsConnector.getInstance(instanceName),
10000,
),
);
return client;
};

/**
Expand Down
9 changes: 8 additions & 1 deletion packages/experiment-browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export { ExperimentConfig } from './config';
export {
AmplitudeUserProvider,
AmplitudeAnalyticsProvider,
} from './integration/amplitude';
} from './providers/amplitude';
export { AmplitudeIntegrationPlugin } from './integration/amplitude';
export { Experiment } from './factory';
export { StubExperimentClient } from './stubClient';
export { ExperimentClient } from './experimentClient';
Expand All @@ -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';
Loading

0 comments on commit 58446e2

Please sign in to comment.