diff --git a/.changeset/spicy-pets-boil.md b/.changeset/spicy-pets-boil.md new file mode 100644 index 000000000..2d89e7fd8 --- /dev/null +++ b/.changeset/spicy-pets-boil.md @@ -0,0 +1,7 @@ +--- +'@segment/analytics-next': minor +'@segment/analytics-core': minor +'@segment/analytics-generic-utils': minor +--- + +Load destinations lazily and start sending events as each becomes available instead of waiting for all to load first diff --git a/packages/browser/package.json b/packages/browser/package.json index 20bef4542..ffd8091c7 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -44,7 +44,7 @@ "size-limit": [ { "path": "dist/umd/index.js", - "limit": "29.5 KB" + "limit": "29.6 KB" } ], "dependencies": { diff --git a/packages/browser/qa/lib/runner.ts b/packages/browser/qa/lib/runner.ts index bac33a49a..2ff502fe4 100644 --- a/packages/browser/qa/lib/runner.ts +++ b/packages/browser/qa/lib/runner.ts @@ -94,7 +94,10 @@ export async function run(params: ComparisonParams) { await page.goto(url) await page.waitForLoadState('networkidle') - await page.waitForFunction(`window.analytics.initialized === true`) + await page.waitForFunction( + `window.analytics.initialized === true`, + undefined + ) // This forces every timestamp to look exactly the same. // Moving this prototype manipulation after networkidle fixed a race condition around Object.freeze that interfered with certain scripts. diff --git a/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts b/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts index 00247bc42..8cc373708 100644 --- a/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts +++ b/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts @@ -1,9 +1,13 @@ -import { sleep } from '@segment/analytics-core' +import { CorePlugin, PluginType, sleep } from '@segment/analytics-core' import { getBufferedPageCtxFixture } from '../../test-helpers/fixtures' import unfetch from 'unfetch' import { AnalyticsBrowser } from '..' import { Analytics } from '../../core/analytics' import { createSuccess } from '../../test-helpers/factories' +import { createDeferred } from '@segment/analytics-generic-utils' +import { PluginFactory } from '../../plugins/remote-loader' + +const nextTickP = () => new Promise((r) => setTimeout(r, 0)) jest.mock('unfetch') @@ -48,3 +52,139 @@ describe('Lazy initialization', () => { ) }) }) + +const createTestPluginFactory = (name: string, type: PluginType) => { + const lock = createDeferred() + const load = createDeferred() + const trackSpy = jest.fn() + + const factory: PluginFactory = () => { + return { + name, + type, + version: '1.0.0', + load: jest + .fn() + .mockImplementation(() => lock.promise.then(() => load.resolve())), + isLoaded: () => lock.isSettled(), + track: trackSpy, + } + } + + factory.pluginName = name + + return { + loadingGuard: lock, + trackSpy, + factory, + loadPromise: load.promise, + } +} + +describe('Lazy destination loading', () => { + beforeEach(() => { + jest.mock('unfetch') + jest.mocked(unfetch).mockImplementation(() => + createSuccess({ + integrations: {}, + remotePlugins: [ + { + name: 'braze', + libraryName: 'braze', + }, + { + name: 'google', + libraryName: 'google', + }, + ], + }) + ) + }) + + afterAll(() => jest.resetAllMocks()) + + it('loads destinations in the background', async () => { + const testEnrichmentHarness = createTestPluginFactory( + 'enrichIt', + 'enrichment' + ) + const dest1Harness = createTestPluginFactory('braze', 'destination') + const dest2Harness = createTestPluginFactory('google', 'destination') + + const analytics = new AnalyticsBrowser() + + const testEnrichmentPlugin = testEnrichmentHarness.factory( + null + ) as CorePlugin + + analytics.register(testEnrichmentPlugin).catch(() => {}) + + await analytics.load({ + writeKey: 'abc', + plugins: [dest1Harness.factory, dest2Harness.factory], + }) + + // we won't hold enrichment plugin from loading since they are not lazy loaded + testEnrichmentHarness.loadingGuard.resolve() + // and we'll also let one destination load so we can assert some behaviours + dest1Harness.loadingGuard.resolve() + + await testEnrichmentHarness.loadPromise + await dest1Harness.loadPromise + + analytics.track('test event 1').catch(() => {}) + + // even though there's one destination that still hasn't loaded, the next assertions + // prove that the event pipeline is flowing regardless + + await nextTickP() + expect(testEnrichmentHarness.trackSpy).toHaveBeenCalledTimes(1) + + await nextTickP() + expect(dest1Harness.trackSpy).toHaveBeenCalledTimes(1) + + // now we'll send another event + + analytics.track('test event 2').catch(() => {}) + + // even though there's one destination that still hasn't loaded, the next assertions + // prove that the event pipeline is flowing regardless + + await nextTickP() + expect(testEnrichmentHarness.trackSpy).toHaveBeenCalledTimes(2) + + await nextTickP() + expect(dest1Harness.trackSpy).toHaveBeenCalledTimes(2) + + // this whole time the other destination was not engaged with at all + expect(dest2Harness.trackSpy).not.toHaveBeenCalled() + + // now "after some time" the other destination will load + dest2Harness.loadingGuard.resolve() + await dest2Harness.loadPromise + + // and now that it is "online" - the previous events that it missed will be handed over + await nextTickP() + expect(dest2Harness.trackSpy).toHaveBeenCalledTimes(2) + }) + + it('emits initialize regardless of whether all destinations have loaded', async () => { + const dest1Harness = createTestPluginFactory('braze', 'destination') + const dest2Harness = createTestPluginFactory('google', 'destination') + + const analytics = new AnalyticsBrowser() + + let initializeEmitted = false + + analytics.on('initialize', () => { + initializeEmitted = true + }) + + await analytics.load({ + writeKey: 'abc', + plugins: [dest1Harness.factory, dest2Harness.factory], + }) + + expect(initializeEmitted).toBe(true) + }) +}) diff --git a/packages/browser/src/browser/__tests__/standalone-analytics.test.ts b/packages/browser/src/browser/__tests__/standalone-analytics.test.ts index 330360d3f..e569b9f93 100644 --- a/packages/browser/src/browser/__tests__/standalone-analytics.test.ts +++ b/packages/browser/src/browser/__tests__/standalone-analytics.test.ts @@ -28,6 +28,7 @@ jest.mock('../../core/analytics', () => ({ addSourceMiddleware, register, emit: jest.fn(), + ready: () => Promise.resolve(), on, queue: new EventQueue(new PersistedPriorityQueue(1, 'event-queue') as any), options, diff --git a/packages/browser/src/browser/index.ts b/packages/browser/src/browser/index.ts index 0c2186e9c..f183217f5 100644 --- a/packages/browser/src/browser/index.ts +++ b/packages/browser/src/browser/index.ts @@ -188,7 +188,6 @@ async function registerPlugins( writeKey: string, legacySettings: LegacySettings, analytics: Analytics, - opts: InitOptions, options: InitOptions, pluginLikes: (Plugin | PluginFactory)[] = [], legacyIntegrationSources: ClassicIntegrationSource[] @@ -222,7 +221,7 @@ async function registerPlugins( writeKey, legacySettings, analytics.integrations, - opts, + options, tsubMiddleware, legacyIntegrationSources ) @@ -237,11 +236,11 @@ async function registerPlugins( }) } - const schemaFilter = opts.plan?.track + const schemaFilter = options.plan?.track ? await import( /* webpackChunkName: "schemaFilter" */ '../plugins/schema-filter' ).then((mod) => { - return mod.schemaFilter(opts.plan?.track, legacySettings) + return mod.schemaFilter(options.plan?.track, legacySettings) }) : undefined @@ -250,7 +249,7 @@ async function registerPlugins( legacySettings, analytics.integrations, mergedSettings, - options.obfuscate, + options, tsubMiddleware, pluginSources ).catch(() => []) @@ -268,8 +267,9 @@ async function registerPlugins( } const shouldIgnoreSegmentio = - (opts.integrations?.All === false && !opts.integrations['Segment.io']) || - (opts.integrations && opts.integrations['Segment.io'] === false) + (options.integrations?.All === false && + !options.integrations['Segment.io']) || + (options.integrations && options.integrations['Segment.io'] === false) if (!shouldIgnoreSegmentio) { toRegister.push( @@ -345,8 +345,12 @@ async function loadAnalytics( const retryQueue: boolean = legacySettings.integrations['Segment.io']?.retryQueue ?? true - const opts: InitOptions = { retryQueue, ...options } - const analytics = new Analytics(settings, opts) + options = { + retryQueue, + ...options, + } + + const analytics = new Analytics(settings, options) attachInspector(analytics) @@ -362,7 +366,6 @@ async function loadAnalytics( settings.writeKey, legacySettings, analytics, - opts, options, plugins, classicIntegrations diff --git a/packages/browser/src/plugins/ajs-destination/index.ts b/packages/browser/src/plugins/ajs-destination/index.ts index 0169d6609..b51a8f86e 100644 --- a/packages/browser/src/plugins/ajs-destination/index.ts +++ b/packages/browser/src/plugins/ajs-destination/index.ts @@ -30,6 +30,7 @@ import { isInstallableIntegration, } from './utils' import { recordIntegrationMetric } from '../../core/stats/metric-helpers' +import { createDeferred } from '@segment/analytics-generic-utils' export type ClassType = new (...args: unknown[]) => T @@ -72,10 +73,10 @@ export class LegacyDestination implements DestinationPlugin { type: Plugin['type'] = 'destination' middleware: DestinationMiddlewareFunction[] = [] - private _ready = false - private _initialized = false + private _ready: boolean | undefined + private _initialized: boolean | undefined private onReady: Promise | undefined - private onInitialize: Promise | undefined + private initializePromise = createDeferred() private disableAutoISOConversion: boolean integrationSource?: ClassicIntegrationSource @@ -104,6 +105,11 @@ export class LegacyDestination implements DestinationPlugin { delete this.settings['type'] } + this.initializePromise.promise.then( + (isInitialized) => (this._initialized = isInitialized), + () => {} + ) + this.options = options this.buffer = options.disableClientPersistence ? new PriorityQueue(4, []) @@ -113,11 +119,13 @@ export class LegacyDestination implements DestinationPlugin { } isLoaded(): boolean { - return this._ready + return !!this._ready } ready(): Promise { - return this.onReady ?? Promise.resolve() + return this.initializePromise.promise.then( + () => this.onReady ?? Promise.resolve() + ) } async load(ctx: Context, analyticsInstance: Analytics): Promise { @@ -149,13 +157,8 @@ export class LegacyDestination implements DestinationPlugin { this.integration!.once('ready', onReadyFn) }) - this.onInitialize = new Promise((resolve) => { - const onInit = (): void => { - this._initialized = true - resolve(true) - } - - this.integration!.on('initialize', onInit) + this.integration!.on('initialize', () => { + this.initializePromise.resolve(true) }) try { @@ -172,6 +175,7 @@ export class LegacyDestination implements DestinationPlugin { type: 'classic', didError: true, }) + this.initializePromise.resolve(false) throw error } } @@ -264,7 +268,7 @@ export class LegacyDestination implements DestinationPlugin { try { if (this.integration) { - await this.integration.invoke.call(this.integration, eventType, event) + await this.integration!.invoke.call(this.integration, eventType, event) } } catch (err) { recordIntegrationMetric(ctx, { @@ -288,9 +292,8 @@ export class LegacyDestination implements DestinationPlugin { this.integration.initialize() } - return this.onInitialize!.then(() => { - return this.send(ctx, Page as ClassType, 'page') - }) + await this.initializePromise.promise + return this.send(ctx, Page as ClassType, 'page') } async identify(ctx: Context): Promise { diff --git a/packages/browser/src/plugins/remote-loader/__tests__/index.test.ts b/packages/browser/src/plugins/remote-loader/__tests__/index.test.ts index 95d3c0abe..a85ec9022 100644 --- a/packages/browser/src/plugins/remote-loader/__tests__/index.test.ts +++ b/packages/browser/src/plugins/remote-loader/__tests__/index.test.ts @@ -60,7 +60,7 @@ describe('Remote Loader', () => { }, {}, {}, - true + { obfuscate: true } ) const btoaName = btoa('to').replace(/=/g, '') expect(loader.loadScript).toHaveBeenCalledWith( @@ -185,7 +185,7 @@ describe('Remote Loader', () => { }, {}, {}, - false, + undefined, undefined, [brazeSpy as unknown as PluginFactory] ) @@ -357,7 +357,7 @@ describe('Remote Loader', () => { expect(plugins).toHaveLength(3) expect(plugins).toEqual( expect.arrayContaining([ - { + expect.objectContaining({ action: one, name: 'multiple plugins', version: '1.0.0', @@ -370,8 +370,8 @@ describe('Remote Loader', () => { identify: expect.any(Function), page: expect.any(Function), screen: expect.any(Function), - }, - { + }), + expect.objectContaining({ action: two, name: 'multiple plugins', version: '1.0.0', @@ -384,8 +384,8 @@ describe('Remote Loader', () => { identify: expect.any(Function), page: expect.any(Function), screen: expect.any(Function), - }, - { + }), + expect.objectContaining({ action: three, name: 'single plugin', version: '1.0.0', @@ -398,7 +398,7 @@ describe('Remote Loader', () => { identify: expect.any(Function), page: expect.any(Function), screen: expect.any(Function), - }, + }), ]) ) expect(multiPluginFactory).toHaveBeenCalledWith({ foo: true }) @@ -500,7 +500,7 @@ describe('Remote Loader', () => { expect(plugins).toHaveLength(1) expect(plugins).toEqual( expect.arrayContaining([ - { + expect.objectContaining({ action: validPlugin, name: 'valid plugin', version: '1.0.0', @@ -513,7 +513,7 @@ describe('Remote Loader', () => { identify: expect.any(Function), page: expect.any(Function), screen: expect.any(Function), - }, + }), ]) ) expect(console.warn).toHaveBeenCalledTimes(1) @@ -827,7 +827,13 @@ describe('Remote Loader', () => { cdnSettings.middlewareSettings!.routingRules ) - const plugins = await remoteLoader(cdnSettings, {}, {}, false, middleware) + const plugins = await remoteLoader( + cdnSettings, + {}, + {}, + undefined, + middleware + ) const plugin = plugins[0] await expect(() => plugin.track!(new Context({ type: 'track', event: 'Item Impression' })) @@ -845,7 +851,7 @@ describe('Remote Loader', () => { name: 'valid', version: '1.0.0', type: 'enrichment', - load: () => {}, + load: () => Promise.resolve(), isLoaded: () => true, track: (ctx: Context) => ctx, } @@ -890,8 +896,15 @@ describe('Remote Loader', () => { const middleware = jest.fn().mockImplementation(() => true) - const plugins = await remoteLoader(cdnSettings, {}, {}, false, middleware) + const plugins = await remoteLoader( + cdnSettings, + {}, + {}, + undefined, + middleware + ) const plugin = plugins[0] as ActionDestination + await plugin.load(new Context(null as any), null as any) plugin.addMiddleware(middleware) await plugin.track(new Context({ type: 'track' })) expect(middleware).not.toHaveBeenCalled() @@ -902,7 +915,7 @@ describe('Remote Loader', () => { name: 'valid', version: '1.0.0', type: 'enrichment', - load: () => {}, + load: () => Promise.resolve(), isLoaded: () => true, track: (ctx: Context) => { ctx.event.name += 'bar' @@ -932,8 +945,9 @@ describe('Remote Loader', () => { return Promise.resolve(true) }) - const plugins = await remoteLoader(cdnSettings, {}, {}, false) + const plugins = await remoteLoader(cdnSettings, {}, {}) const plugin = plugins[0] as ActionDestination + await plugin.load(new Context(null as any), null as any) const newCtx = await plugin.track( new Context({ type: 'track', name: 'foo' }) ) diff --git a/packages/browser/src/plugins/remote-loader/index.ts b/packages/browser/src/plugins/remote-loader/index.ts index 5cca5490e..b0979fa19 100644 --- a/packages/browser/src/plugins/remote-loader/index.ts +++ b/packages/browser/src/plugins/remote-loader/index.ts @@ -9,8 +9,9 @@ import { DestinationMiddlewareFunction, } from '../middleware' import { Context, ContextCancelation } from '../../core/context' -import { Analytics } from '../../core/analytics' import { recordIntegrationMetric } from '../../core/stats/metric-helpers' +import { Analytics, InitOptions } from '../../core/analytics' +import { createDeferred } from '@segment/analytics-generic-utils' export interface RemotePlugin { /** The name of the remote plugin */ @@ -32,6 +33,8 @@ export class ActionDestination implements DestinationPlugin { alternativeNames: string[] = [] + private loadPromise = createDeferred() + middleware: DestinationMiddlewareFunction[] = [] action: Plugin @@ -81,11 +84,18 @@ export class ActionDestination implements DestinationPlugin { } try { + if (!(await this.ready())) { + throw new Error( + 'Something prevented the destination from getting ready' + ) + } + recordIntegrationMetric(ctx, { integrationName: this.action.name, methodName, type: 'action', }) + await this.action[methodName]!(transformedContext) } catch (error) { recordIntegrationMetric(ctx, { @@ -113,18 +123,31 @@ export class ActionDestination implements DestinationPlugin { return this.action.isLoaded() } - ready(): Promise { - return this.action.ready ? this.action.ready() : Promise.resolve() + async ready(): Promise { + try { + await this.loadPromise.promise + return true + } catch { + return false + } } async load(ctx: Context, analytics: Analytics): Promise { + if (this.loadPromise.isSettled()) { + return this.loadPromise.promise + } + try { recordIntegrationMetric(ctx, { integrationName: this.action.name, methodName: 'load', type: 'action', }) - return await this.action.load(ctx, analytics) + + const loadP = this.action.load(ctx, analytics) + + this.loadPromise.resolve(await loadP) + return loadP } catch (error) { recordIntegrationMetric(ctx, { integrationName: this.action.name, @@ -132,6 +155,8 @@ export class ActionDestination implements DestinationPlugin { type: 'action', didError: true, }) + + this.loadPromise.reject(error) throw error } } @@ -227,7 +252,7 @@ export async function remoteLoader( settings: LegacySettings, userIntegrations: Integrations, mergedIntegrations: Record, - obfuscate?: boolean, + options?: InitOptions, routingMiddleware?: DestinationMiddlewareFunction, pluginSources?: PluginFactory[] ): Promise { @@ -243,7 +268,7 @@ export async function remoteLoader( const pluginFactory = pluginSources?.find( ({ pluginName }) => pluginName === remotePlugin.name - ) || (await loadPluginFactory(remotePlugin, obfuscate)) + ) || (await loadPluginFactory(remotePlugin, options?.obfuscate)) if (pluginFactory) { const plugin = await pluginFactory({ diff --git a/packages/core/src/queue/event-queue.ts b/packages/core/src/queue/event-queue.ts index 81af660bc..4ca14d60a 100644 --- a/packages/core/src/queue/event-queue.ts +++ b/packages/core/src/queue/event-queue.ts @@ -16,6 +16,7 @@ export type EventQueueEmitterContract = { delivery_retry: [ctx: Ctx] delivery_failure: [ctx: Ctx, err: Ctx | Error | ContextCancelation] flush: [ctx: Ctx, delivered: boolean] + initialization_failure: [CorePlugin] } export abstract class CoreEventQueue< @@ -49,25 +50,24 @@ export abstract class CoreEventQueue< plugin: Plugin, instance: CoreAnalytics ): Promise { - await Promise.resolve(plugin.load(ctx, instance)) - .then(() => { - this.plugins.push(plugin) + if (plugin.type === 'destination' && plugin.name !== 'Segment.io') { + plugin.load(ctx, instance).catch((err) => { + this.failedInitializations.push(plugin.name) + this.emit('initialization_failure', plugin) + console.warn(plugin.name, err) + + ctx.log('warn', 'Failed to load destination', { + plugin: plugin.name, + error: err, + }) + + this.plugins = this.plugins.filter((p) => p === plugin) }) - .catch((err) => { - if (plugin.type === 'destination') { - this.failedInitializations.push(plugin.name) - console.warn(plugin.name, err) - - ctx.log('warn', 'Failed to load destination', { - plugin: plugin.name, - error: err, - }) - - return - } + } else { + await plugin.load(ctx, instance) + } - throw err - }) + this.plugins.push(plugin) } async deregister( diff --git a/packages/generic-utils/src/create-deferred/__tests__/create-deferred.test.ts b/packages/generic-utils/src/create-deferred/__tests__/create-deferred.test.ts index 5b14798fa..f2b2cc80f 100644 --- a/packages/generic-utils/src/create-deferred/__tests__/create-deferred.test.ts +++ b/packages/generic-utils/src/create-deferred/__tests__/create-deferred.test.ts @@ -36,4 +36,19 @@ describe(createDeferred, () => { expect(isRejected).toBe(true) expect(isRejectedVal).toBe('foo') }) + + test('isSettled return true on either reject or resolve', async () => { + const deferred1 = createDeferred() + const deferred2 = createDeferred() + + expect(deferred1.isSettled()).toBe(false) + expect(deferred2.isSettled()).toBe(false) + + deferred1.resolve('foo') + expect(deferred1.isSettled()).toBe(true) + + deferred2.promise.catch(() => {}) + deferred2.reject('foo') + expect(deferred2.isSettled()).toBe(true) + }) }) diff --git a/packages/generic-utils/src/create-deferred/create-deferred.ts b/packages/generic-utils/src/create-deferred/create-deferred.ts index 66c3b5d7a..88b92ea71 100644 --- a/packages/generic-utils/src/create-deferred/create-deferred.ts +++ b/packages/generic-utils/src/create-deferred/create-deferred.ts @@ -4,13 +4,22 @@ export const createDeferred = () => { let resolve!: (value: T | PromiseLike) => void let reject!: (reason: any) => void + let settled = false const promise = new Promise((_resolve, _reject) => { - resolve = _resolve - reject = _reject + resolve = (...args) => { + settled = true + _resolve(...args) + } + reject = (...args) => { + settled = true + _reject(...args) + } }) + return { resolve, reject, promise, + isSettled: () => settled, } }