diff --git a/packages/analytics-browser/src/browser-client.ts b/packages/analytics-browser/src/browser-client.ts index ed1ab5eb50..352d20e35a 100644 --- a/packages/analytics-browser/src/browser-client.ts +++ b/packages/analytics-browser/src/browser-client.ts @@ -304,7 +304,7 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient, An // Set up the analytics connector to integrate with the experiment SDK. // Send events from the experiment SDK and forward identifies to the // identity store. - const connector = getAnalyticsConnector(options.instanceName); + const connector = getAnalyticsConnector(this.config.instanceName); connector.identityStore.setIdentity({ userId: this.config.userId, deviceId: this.config.deviceId, diff --git a/packages/analytics-browser/src/config.ts b/packages/analytics-browser/src/config.ts index cc8c9a309c..a61cc26361 100644 --- a/packages/analytics-browser/src/config.ts +++ b/packages/analytics-browser/src/config.ts @@ -76,7 +76,7 @@ export class BrowserConfig extends Config implements IBrowserConfig { public flushQueueSize: number = 30, public identityStorage: IdentityStorageType = DEFAULT_IDENTITY_STORAGE, public ingestionMetadata?: IngestionMetadata, - public instanceName?: string, + instanceName?: string, lastEventId?: number, lastEventTime?: number, public loggerProvider: ILogger = new Logger(), @@ -113,7 +113,7 @@ export class BrowserConfig extends Config implements IBrowserConfig { public enableRequestBodyCompression: boolean = false, public customEnrichment?: boolean | CustomEnrichmentOptions, ) { - super({ apiKey, storageProvider, transportProvider: createTransport(transport) }); + super({ apiKey, instanceName, storageProvider, transportProvider: createTransport(transport) }); this._cookieStorage = cookieStorage; this.deviceId = deviceId; this.lastEventId = lastEventId; diff --git a/packages/analytics-browser/test/browser-client.test.ts b/packages/analytics-browser/test/browser-client.test.ts index 5aca152ef0..662b686d81 100644 --- a/packages/analytics-browser/test/browser-client.test.ts +++ b/packages/analytics-browser/test/browser-client.test.ts @@ -579,6 +579,58 @@ describe('browser-client', () => { expect(identity.userId).toBe(userId); }); + test('should use the default connector for an empty instance name', async () => { + await client.init(apiKey, userId, { + optOut: true, + defaultTracking, + deviceId, + identityStorage: 'none', + instanceName: '', + }).promise; + + const connector = getAnalyticsConnector(); + expect(client.config.instanceName).toBe('$default_instance'); + expect(getAnalyticsConnector('')).toBe(connector); + expect(connector.identityStore.getIdentity()).toMatchObject({ userId, deviceId }); + + const nextUserId = core.UUID(); + const nextDeviceId = core.UUID(); + client.setUserId(nextUserId); + client.setDeviceId(nextDeviceId); + expect(connector.identityStore.getIdentity()).toMatchObject({ userId: nextUserId, deviceId: nextDeviceId }); + + const track = jest.spyOn(client, 'track').mockReturnValueOnce({ + promise: Promise.resolve({ + code: 200, + message: '', + event: { event_type: 'experiment-exposure' }, + }), + }); + connector.eventBridge.logEvent({ eventType: 'experiment-exposure' }); + expect(track).toHaveBeenCalledWith('experiment-exposure', {}, undefined); + }); + + test('should isolate a custom instance from the default connector', async () => { + const instanceName = `custom-${core.UUID()}`; + const defaultConnector = getAnalyticsConnector(); + defaultConnector.identityStore.setIdentity({ userId: 'default-user', deviceId: 'default-device' }); + + await client.init(apiKey, userId, { + optOut: true, + defaultTracking, + deviceId, + identityStorage: 'none', + instanceName, + }).promise; + + expect(client.config.instanceName).toBe(instanceName); + expect(getAnalyticsConnector(instanceName).identityStore.getIdentity()).toMatchObject({ userId, deviceId }); + expect(defaultConnector.identityStore.getIdentity()).toMatchObject({ + userId: 'default-user', + deviceId: 'default-device', + }); + }); + test('should set up event bridge and track events', async () => { await client.init(apiKey, userId, { optOut: false, diff --git a/packages/analytics-browser/test/config.test.ts b/packages/analytics-browser/test/config.test.ts index 87129e6a03..8af06a3253 100644 --- a/packages/analytics-browser/test/config.test.ts +++ b/packages/analytics-browser/test/config.test.ts @@ -54,6 +54,7 @@ describe('config', () => { }, defaultTracking: undefined, identityStorage: DEFAULT_IDENTITY_STORAGE, + instanceName: '$default_instance', flushIntervalMillis: 1000, flushMaxRetries: 5, flushQueueSize: 30, @@ -98,6 +99,31 @@ describe('config', () => { expect(config.fetchRemoteConfig).toBe(true); expect(config.remoteConfig?.fetchRemoteConfig).toBe(true); }); + + test.each([ + [undefined, '$default_instance'], + ['', '$default_instance'], + ['custom-instance', 'custom-instance'], + [' ', ' '], + ])('should retain normalized instance name %p as %p', (instanceName, expected) => { + const config = new Config.BrowserConfig( + apiKey, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + instanceName, + ); + + expect(config.instanceName).toBe(expected); + }); }); describe('useBrowserConfig', () => { @@ -125,6 +151,7 @@ describe('config', () => { }, defaultTracking: undefined, identityStorage: DEFAULT_IDENTITY_STORAGE, + instanceName: '$default_instance', flushIntervalMillis: 1000, flushMaxRetries: 5, flushQueueSize: 30, @@ -251,6 +278,7 @@ describe('config', () => { flushMaxRetries: 5, flushQueueSize: 30, identityStorage: DEFAULT_IDENTITY_STORAGE, + instanceName: '$default_instance', ingestionMetadata: { sourceName: 'ampli', sourceVersion: '2.0.0', diff --git a/packages/analytics-core/src/analytics-connector.ts b/packages/analytics-core/src/analytics-connector.ts index cea65f3b7b..93144769be 100644 --- a/packages/analytics-core/src/analytics-connector.ts +++ b/packages/analytics-core/src/analytics-connector.ts @@ -1,8 +1,8 @@ import { AnalyticsConnector } from '@amplitude/analytics-connector'; -import { DEFAULT_INSTANCE_NAME } from './types/constants'; +import { normalizeInstanceName } from './utils/instance-name'; -export const getAnalyticsConnector = (instanceName = DEFAULT_INSTANCE_NAME): AnalyticsConnector => { - return AnalyticsConnector.getInstance(instanceName); +export const getAnalyticsConnector = (instanceName?: string): AnalyticsConnector => { + return AnalyticsConnector.getInstance(normalizeInstanceName(instanceName)); }; export const setConnectorUserId = (userId: string | undefined, instanceName?: string): void => { diff --git a/packages/analytics-core/src/config.ts b/packages/analytics-core/src/config.ts index 7b747f10ac..8b551d916e 100644 --- a/packages/analytics-core/src/config.ts +++ b/packages/analytics-core/src/config.ts @@ -16,6 +16,7 @@ import { import { Logger, ILogger } from './logger'; import { LogLevel } from './types/loglevel'; import { ConfigOptions, IRequestMetadata, IHistogramOptions, HistogramKey, IConfig } from './types/config/core-config'; +import { normalizeInstanceName } from './utils/instance-name'; export const getDefaultConfig = () => ({ flushMaxRetries: 12, @@ -63,7 +64,7 @@ export class Config implements IConfig { this.flushIntervalMillis = options.flushIntervalMillis ?? defaultConfig.flushIntervalMillis; this.flushMaxRetries = options.flushMaxRetries || defaultConfig.flushMaxRetries; this.flushQueueSize = options.flushQueueSize || defaultConfig.flushQueueSize; - this.instanceName = options.instanceName || defaultConfig.instanceName; + this.instanceName = normalizeInstanceName(options.instanceName); this.loggerProvider = options.loggerProvider || defaultConfig.loggerProvider; this.logLevel = options.logLevel ?? defaultConfig.logLevel; this.minIdLength = options.minIdLength; diff --git a/packages/analytics-core/src/index.ts b/packages/analytics-core/src/index.ts index bc0d9b410b..58e1176980 100644 --- a/packages/analytics-core/src/index.ts +++ b/packages/analytics-core/src/index.ts @@ -12,6 +12,7 @@ export { IConfig } from './types/config/core-config'; export { Logger, ILogger, LogConfig } from './logger'; export { getGlobalScope } from './global-scope'; export { getAnalyticsConnector, setConnectorDeviceId, setConnectorUserId } from './analytics-connector'; +export { normalizeInstanceName } from './utils/instance-name'; export { isNewSession } from './session'; export { getCookieName, getOldCookieName } from './cookie-name'; export { getLanguage } from './language'; diff --git a/packages/analytics-core/src/utils/instance-name.ts b/packages/analytics-core/src/utils/instance-name.ts new file mode 100644 index 0000000000..be71973d4d --- /dev/null +++ b/packages/analytics-core/src/utils/instance-name.ts @@ -0,0 +1,5 @@ +import { DEFAULT_INSTANCE_NAME } from '../types/constants'; + +export const normalizeInstanceName = (instanceName?: string): string => { + return instanceName === undefined || instanceName === '' ? DEFAULT_INSTANCE_NAME : instanceName; +}; diff --git a/packages/analytics-core/test/analytics-connector.test.ts b/packages/analytics-core/test/analytics-connector.test.ts index 77d65b9aa9..9514ce7ec1 100644 --- a/packages/analytics-core/test/analytics-connector.test.ts +++ b/packages/analytics-core/test/analytics-connector.test.ts @@ -1,5 +1,6 @@ import { AnalyticsConnector } from '@amplitude/analytics-connector'; import { getAnalyticsConnector, setConnectorDeviceId, setConnectorUserId } from '../src/analytics-connector'; +import { DEFAULT_INSTANCE_NAME } from '../src/types/constants'; describe('analytics-connector', () => { describe('getAnalyticsConnector', () => { @@ -9,6 +10,19 @@ describe('analytics-connector', () => { expect(getAnalyticsConnector()).toBe(instance); expect(getInstance).toHaveBeenCalledTimes(1); }); + + test.each([ + [undefined, DEFAULT_INSTANCE_NAME], + ['', DEFAULT_INSTANCE_NAME], + ['custom-instance', 'custom-instance'], + [' ', ' '], + ])('should normalize instance name %p to %p', (instanceName, expected) => { + const instance = new AnalyticsConnector(); + const getInstance = jest.spyOn(AnalyticsConnector, 'getInstance').mockReturnValueOnce(instance); + + expect(getAnalyticsConnector(instanceName)).toBe(instance); + expect(getInstance).toHaveBeenCalledWith(expected); + }); }); describe('setConnectorUserId', () => { diff --git a/packages/analytics-core/test/config.test.ts b/packages/analytics-core/test/config.test.ts index 1426f25556..42a0931e3d 100644 --- a/packages/analytics-core/test/config.test.ts +++ b/packages/analytics-core/test/config.test.ts @@ -127,6 +127,23 @@ describe('config', () => { }); expect(config.flushIntervalMillis).toEqual(0); }); + + test.each([ + [undefined, DEFAULT_INSTANCE_NAME], + ['', DEFAULT_INSTANCE_NAME], + ['custom-instance', 'custom-instance'], + [' ', ' '], + ])('should normalize instance name %p to %p', (instanceName, expected) => { + const defaultConfig = useDefaultConfig(); + const config = new Config({ + apiKey: API_KEY, + instanceName, + storageProvider: defaultConfig.storageProvider, + transportProvider: defaultConfig.transportProvider, + }); + + expect(config.instanceName).toBe(expected); + }); }); describe('RequestMetadata', () => { diff --git a/packages/analytics-core/test/index.test.ts b/packages/analytics-core/test/index.test.ts index 18e69773d2..1f1b8f6e6f 100644 --- a/packages/analytics-core/test/index.test.ts +++ b/packages/analytics-core/test/index.test.ts @@ -18,6 +18,7 @@ import { RequestMetadata, getGlobalScope, getAnalyticsConnector, + normalizeInstanceName, setConnectorDeviceId, setConnectorUserId, isNewSession, @@ -112,6 +113,7 @@ describe('index', () => { expect(typeof getStorageKey).toBe('function'); expect(typeof getGlobalScope).toBe('function'); expect(typeof getAnalyticsConnector).toBe('function'); + expect(typeof normalizeInstanceName).toBe('function'); expect(typeof setConnectorDeviceId).toBe('function'); expect(typeof setConnectorUserId).toBe('function'); expect(typeof isNewSession).toBe('function'); diff --git a/packages/plugin-experiment-browser/src/experiment.ts b/packages/plugin-experiment-browser/src/experiment.ts index b9b141ebb1..d90764ac6b 100644 --- a/packages/plugin-experiment-browser/src/experiment.ts +++ b/packages/plugin-experiment-browser/src/experiment.ts @@ -24,7 +24,11 @@ export class ExperimentPlugin implements EnrichmentPlugin { language: true, platform: true, }, + instanceName: '$default_instance', } as unknown as BrowserConfig; const mockAmplitude: MockedBrowserClient = { add: jest.fn(), @@ -86,7 +87,10 @@ describe('ExperimentPlugin', () => { (initializeWithAmplitudeAnalytics as jest.Mock).mockReturnValue(mockExperimentClient); const plugin = new ExperimentPlugin(experimentConfig); await plugin.setup(mockConfig, mockAmplitude); - expect(initializeWithAmplitudeAnalytics).toHaveBeenCalledWith(mockConfig.apiKey, experimentConfig); + expect(initializeWithAmplitudeAnalytics).toHaveBeenCalledWith(mockConfig.apiKey, { + ...(experimentConfig ?? {}), + instanceName: mockConfig.instanceName, + }); expect(plugin.experiment).toBe(mockExperimentClient); }); @@ -99,9 +103,33 @@ describe('ExperimentPlugin', () => { (initializeWithAmplitudeAnalytics as jest.Mock).mockReturnValue(mockExperimentClient); const plugin = new ExperimentPlugin(experimentConfig); await plugin.setup(mockConfig, mockAmplitude); - expect(initializeWithAmplitudeAnalytics).toHaveBeenCalledWith(experimentConfig.deploymentKey, experimentConfig); + expect(initializeWithAmplitudeAnalytics).toHaveBeenCalledWith(experimentConfig.deploymentKey, { + ...experimentConfig, + instanceName: mockConfig.instanceName, + }); expect(plugin.experiment).toBe(mockExperimentClient); }); + + test('should inherit a custom analytics instance name', async () => { + const plugin = new ExperimentPlugin({ debug: true }); + await plugin.setup({ ...mockConfig, instanceName: 'analytics-instance' }, mockAmplitude); + + expect(initializeWithAmplitudeAnalytics).toHaveBeenCalledWith(mockConfig.apiKey, { + debug: true, + instanceName: 'analytics-instance', + }); + }); + + test.each(['experiment-instance', ''])( + 'should preserve an explicit experiment instance name %p', + async (instanceName) => { + const experimentConfig: ExperimentPluginConfig = { instanceName }; + const plugin = new ExperimentPlugin(experimentConfig); + await plugin.setup({ ...mockConfig, instanceName: 'analytics-instance' }, mockAmplitude); + + expect(initializeWithAmplitudeAnalytics).toHaveBeenCalledWith(mockConfig.apiKey, experimentConfig); + }, + ); }); describe('experimentPlugin', () => {