Skip to content

Commit d98dcd2

Browse files
authored
Fix plugin pipeline race condition (#1121)
1 parent 6bfaa3e commit d98dcd2

File tree

10 files changed

+418
-130
lines changed

10 files changed

+418
-130
lines changed

.changeset/giant-monkeys-count.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@segment/analytics-next': patch
3+
---
4+
5+
Fix enrichment plugins not waiting for .load to resolve when plugin is registered manually

packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts

Lines changed: 140 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,31 @@
11
import { CorePlugin, PluginType, sleep } from '@segment/analytics-core'
2-
import { getBufferedPageCtxFixture } from '../../test-helpers/fixtures'
2+
import {
3+
createMockFetchImplementation,
4+
createRemotePlugin,
5+
getBufferedPageCtxFixture,
6+
} from '../../test-helpers/fixtures'
37
import unfetch from 'unfetch'
48
import { AnalyticsBrowser } from '..'
59
import { Analytics } from '../../core/analytics'
6-
import { createSuccess } from '../../test-helpers/factories'
710
import { createDeferred } from '@segment/analytics-generic-utils'
811
import { PluginFactory } from '../../plugins/remote-loader'
912

1013
const nextTickP = () => new Promise((r) => setTimeout(r, 0))
1114

1215
jest.mock('unfetch')
1316

14-
const mockFetchSettingsSuccessResponse = () => {
15-
return jest
16-
.mocked(unfetch)
17-
.mockImplementation(() => createSuccess({ integrations: {} }))
18-
}
17+
beforeEach(() => {
18+
document.head.innerHTML = `
19+
<script id="initial"></script>`.trim()
20+
})
1921

2022
describe('Lazy initialization', () => {
2123
let trackSpy: jest.SpiedFunction<Analytics['track']>
2224
let fetched: jest.MockedFn<typeof unfetch>
2325
beforeEach(() => {
24-
fetched = mockFetchSettingsSuccessResponse()
26+
fetched = jest
27+
.mocked(unfetch)
28+
.mockImplementation(createMockFetchImplementation())
2529
trackSpy = jest.spyOn(Analytics.prototype, 'track')
2630
})
2731

@@ -56,7 +60,10 @@ describe('Lazy initialization', () => {
5660
const createTestPluginFactory = (name: string, type: PluginType) => {
5761
const lock = createDeferred<void>()
5862
const load = createDeferred<void>()
59-
const trackSpy = jest.fn()
63+
const trackSpy = jest.fn().mockImplementation((ctx) => {
64+
ctx.event.context!.ran = true
65+
return ctx
66+
})
6067

6168
const factory: PluginFactory = () => {
6269
return {
@@ -83,91 +90,158 @@ const createTestPluginFactory = (name: string, type: PluginType) => {
8390

8491
describe('Lazy destination loading', () => {
8592
beforeEach(() => {
86-
jest.mock('unfetch')
87-
jest.mocked(unfetch).mockImplementation(() =>
88-
createSuccess({
89-
integrations: {},
93+
jest.mocked(unfetch).mockImplementation(
94+
createMockFetchImplementation({
95+
integrations: {
96+
braze: {},
97+
google: {},
98+
},
9099
remotePlugins: [
91-
{
92-
name: 'braze',
93-
libraryName: 'braze',
94-
},
95-
{
96-
name: 'google',
97-
libraryName: 'google',
98-
},
100+
createRemotePlugin('braze'),
101+
createRemotePlugin('google'),
99102
],
100103
})
101104
)
102105
})
103106

104107
afterAll(() => jest.resetAllMocks())
105108

106-
it('loads destinations in the background', async () => {
107-
const testEnrichmentHarness = createTestPluginFactory(
108-
'enrichIt',
109-
'enrichment'
110-
)
111-
const dest1Harness = createTestPluginFactory('braze', 'destination')
112-
const dest2Harness = createTestPluginFactory('google', 'destination')
109+
describe('critical plugins (plugins that block the event pipeline)', () => {
110+
test('pipeline _will_ wait for *enrichment* plugins to load', async () => {
111+
jest.mocked(unfetch).mockImplementation(
112+
createMockFetchImplementation({
113+
remotePlugins: [],
114+
})
115+
)
116+
const testEnrichmentHarness = createTestPluginFactory(
117+
'enrichIt',
118+
'enrichment'
119+
)
113120

114-
const analytics = new AnalyticsBrowser()
121+
const analytics = new AnalyticsBrowser()
115122

116-
const testEnrichmentPlugin = testEnrichmentHarness.factory(
117-
null
118-
) as CorePlugin
123+
const testPlugin = testEnrichmentHarness.factory(null) as CorePlugin
119124

120-
analytics.register(testEnrichmentPlugin).catch(() => {})
125+
analytics.register(testPlugin).catch(() => {})
126+
analytics.track('test event 1').catch(() => {})
121127

122-
await analytics.load({
123-
writeKey: 'abc',
124-
plugins: [dest1Harness.factory, dest2Harness.factory],
128+
const analyticsLoaded = analytics.load({
129+
writeKey: 'abc',
130+
plugins: [],
131+
})
132+
133+
expect(testEnrichmentHarness.trackSpy).not.toHaveBeenCalled()
134+
135+
// now we'll let the enrichment plugin load
136+
testEnrichmentHarness.loadingGuard.resolve()
137+
138+
await analyticsLoaded
139+
await sleep(200)
140+
expect(testEnrichmentHarness.trackSpy).toHaveBeenCalledTimes(1)
141+
})
142+
143+
test('pipeline _will_ wait for *before* plugins to load', async () => {
144+
jest.mocked(unfetch).mockImplementation(
145+
createMockFetchImplementation({
146+
remotePlugins: [],
147+
})
148+
)
149+
const testBeforeHarness = createTestPluginFactory('enrichIt', 'before')
150+
151+
const analytics = new AnalyticsBrowser()
152+
153+
const testPlugin = testBeforeHarness.factory(null) as CorePlugin
154+
155+
analytics.register(testPlugin).catch(() => {})
156+
analytics.track('test event 1').catch(() => {})
157+
158+
const analyticsLoaded = analytics.load({
159+
writeKey: 'abc',
160+
plugins: [],
161+
})
162+
163+
expect(testBeforeHarness.trackSpy).not.toHaveBeenCalled()
164+
165+
// now we'll let the before plugin load
166+
testBeforeHarness.loadingGuard.resolve()
167+
168+
await analyticsLoaded
169+
await sleep(200)
170+
expect(testBeforeHarness.trackSpy).toHaveBeenCalledTimes(1)
125171
})
172+
})
126173

127-
// we won't hold enrichment plugin from loading since they are not lazy loaded
128-
testEnrichmentHarness.loadingGuard.resolve()
129-
// and we'll also let one destination load so we can assert some behaviours
130-
dest1Harness.loadingGuard.resolve()
174+
describe('non-critical plugins (plugins that do not block the event pipeline)', () => {
175+
it('destination loading does not block the event pipeline, but enrichment plugins do', async () => {
176+
const testEnrichmentHarness = createTestPluginFactory(
177+
'enrichIt',
178+
'enrichment'
179+
)
180+
const dest1Harness = createTestPluginFactory('braze', 'destination')
181+
const dest2Harness = createTestPluginFactory('google', 'destination')
131182

132-
await testEnrichmentHarness.loadPromise
133-
await dest1Harness.loadPromise
183+
const analytics = new AnalyticsBrowser()
134184

135-
analytics.track('test event 1').catch(() => {})
185+
const testEnrichmentPlugin = testEnrichmentHarness.factory(
186+
null
187+
) as CorePlugin
136188

137-
// even though there's one destination that still hasn't loaded, the next assertions
138-
// prove that the event pipeline is flowing regardless
189+
analytics.register(testEnrichmentPlugin).catch(() => {})
139190

140-
await nextTickP()
141-
expect(testEnrichmentHarness.trackSpy).toHaveBeenCalledTimes(1)
191+
const p = analytics.load({
192+
writeKey: 'abc',
193+
plugins: [dest1Harness.factory, dest2Harness.factory],
194+
})
142195

143-
await nextTickP()
144-
expect(dest1Harness.trackSpy).toHaveBeenCalledTimes(1)
196+
// we won't hold enrichment plugin from loading since they are not lazy loaded
197+
testEnrichmentHarness.loadingGuard.resolve()
198+
await p
199+
// and we'll also let one destination load so we can assert some behaviours
200+
dest1Harness.loadingGuard.resolve()
145201

146-
// now we'll send another event
202+
analytics.track('test event 1').catch(() => {})
147203

148-
analytics.track('test event 2').catch(() => {})
204+
// even though there's one destination that still hasn't loaded, the next assertions
205+
// prove that the event pipeline is flowing regardless
149206

150-
// even though there's one destination that still hasn't loaded, the next assertions
151-
// prove that the event pipeline is flowing regardless
207+
await nextTickP()
208+
expect(testEnrichmentHarness.trackSpy).toHaveBeenCalledTimes(1)
152209

153-
await nextTickP()
154-
expect(testEnrichmentHarness.trackSpy).toHaveBeenCalledTimes(2)
210+
await nextTickP()
211+
expect(dest1Harness.trackSpy).toHaveBeenCalledTimes(1)
212+
expect(dest1Harness.trackSpy.mock.calls[0][0].event.context.ran).toBe(
213+
true
214+
)
155215

156-
await nextTickP()
157-
expect(dest1Harness.trackSpy).toHaveBeenCalledTimes(2)
216+
// now we'll send another event
158217

159-
// this whole time the other destination was not engaged with at all
160-
expect(dest2Harness.trackSpy).not.toHaveBeenCalled()
218+
analytics.track('test event 2').catch(() => {})
161219

162-
// now "after some time" the other destination will load
163-
dest2Harness.loadingGuard.resolve()
164-
await dest2Harness.loadPromise
220+
// even though there's one destination that still hasn't loaded, the next assertions
221+
// prove that the event pipeline is flowing regardless
165222

166-
// and now that it is "online" - the previous events that it missed will be handed over
167-
await nextTickP()
168-
expect(dest2Harness.trackSpy).toHaveBeenCalledTimes(2)
169-
})
223+
await nextTickP()
224+
expect(testEnrichmentHarness.trackSpy).toHaveBeenCalledTimes(2)
225+
226+
await nextTickP()
227+
expect(dest1Harness.trackSpy).toHaveBeenCalledTimes(2)
228+
229+
// this whole time the other destination was not engaged with at all
230+
expect(dest2Harness.trackSpy).not.toHaveBeenCalled()
170231

232+
// now "after some time" the other destination will load
233+
dest2Harness.loadingGuard.resolve()
234+
await dest2Harness.loadPromise
235+
236+
// and now that it is "online" - the previous events that it missed will be handed over
237+
await nextTickP()
238+
expect(dest2Harness.trackSpy).toHaveBeenCalledTimes(2)
239+
240+
// should not add any other script tags
241+
expect(document.querySelectorAll('script').length).toBe(1)
242+
expect(document.getElementsByTagName('script')[0].id).toBe('initial')
243+
})
244+
})
171245
it('emits initialize regardless of whether all destinations have loaded', async () => {
172246
const dest1Harness = createTestPluginFactory('braze', 'destination')
173247
const dest2Harness = createTestPluginFactory('google', 'destination')

packages/browser/src/browser/__tests__/integration.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,8 @@ describe('register', () => {
10911091
...googleAnalytics,
10921092
load: () => Promise.resolve('foo'),
10931093
})
1094+
1095+
await analytics
10941096
const goodPluginSpy = jest.spyOn(goodPlugin, 'track')
10951097

10961098
await analytics.register(goodPlugin, errorPlugin)

packages/browser/src/browser/index.ts

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
flushSetAnonymousID,
2626
flushOn,
2727
PreInitMethodCall,
28+
flushRegister,
2829
} from '../core/buffer'
2930
import { ClassicIntegrationSource } from '../plugins/ajs-destination/types'
3031
import { attachInspector } from '../core/inspector'
@@ -208,8 +209,6 @@ async function flushFinalBuffer(
208209
// analytics calls during async function calls.
209210
await flushAddSourceMiddleware(analytics, buffer)
210211
flushAnalyticsCallsInNewTask(analytics, buffer)
211-
// Clear buffer, just in case analytics is loaded twice; we don't want to fire events off again.
212-
buffer.clear()
213212
}
214213

215214
async function registerPlugins(
@@ -218,9 +217,11 @@ async function registerPlugins(
218217
analytics: Analytics,
219218
options: InitOptions,
220219
pluginLikes: (Plugin | PluginFactory)[] = [],
221-
legacyIntegrationSources: ClassicIntegrationSource[]
220+
legacyIntegrationSources: ClassicIntegrationSource[],
221+
preInitBuffer: PreInitMethodCallBuffer
222222
): Promise<Context> {
223-
const plugins = pluginLikes?.filter(
223+
flushPreBuffer(analytics, preInitBuffer)
224+
const pluginsFromSettings = pluginLikes?.filter(
224225
(pluginLike) => typeof pluginLike === 'object'
225226
) as Plugin[]
226227

@@ -280,15 +281,10 @@ async function registerPlugins(
280281
pluginSources
281282
).catch(() => [])
282283

283-
const toRegister = [
284-
envEnrichment,
285-
...plugins,
286-
...legacyDestinations,
287-
...remotePlugins,
288-
]
284+
const basePlugins = [envEnrichment, ...legacyDestinations, ...remotePlugins]
289285

290286
if (schemaFilter) {
291-
toRegister.push(schemaFilter)
287+
basePlugins.push(schemaFilter)
292288
}
293289

294290
const shouldIgnoreSegmentio =
@@ -297,7 +293,7 @@ async function registerPlugins(
297293
(options.integrations && options.integrations['Segment.io'] === false)
298294

299295
if (!shouldIgnoreSegmentio) {
300-
toRegister.push(
296+
basePlugins.push(
301297
await segmentio(
302298
analytics,
303299
mergedSettings['Segment.io'] as SegmentioSettings,
@@ -306,7 +302,15 @@ async function registerPlugins(
306302
)
307303
}
308304

309-
const ctx = await analytics.register(...toRegister)
305+
// order is important here, (for example, if there are multiple enrichment plugins, the last registered plugin will have access to the last context.)
306+
const ctx = await analytics.register(
307+
// register 'core' plugins and those via destinations
308+
...basePlugins,
309+
// register user-defined plugins passed into AnalyticsBrowser.load({ plugins: [plugin1, plugin2] }) -- relevant to npm-only
310+
...pluginsFromSettings
311+
)
312+
// register user-defined plugins registered via analytics.register()
313+
await flushRegister(analytics, preInitBuffer)
310314

311315
if (
312316
Object.entries(cdnSettings.enabledMiddleware ?? {}).some(
@@ -348,7 +352,7 @@ async function loadAnalytics(
348352

349353
if (options.initialPageview) {
350354
// capture the page context early, so it's always up-to-date
351-
preInitBuffer.push(new PreInitMethodCall('page', []))
355+
preInitBuffer.add(new PreInitMethodCall('page', []))
352356
}
353357

354358
let cdnSettings =
@@ -393,16 +397,14 @@ async function loadAnalytics(
393397
protocol: segmentLoadOptions?.protocol,
394398
})
395399

396-
// needs to be flushed before plugins are registered
397-
flushPreBuffer(analytics, preInitBuffer)
398-
399400
const ctx = await registerPlugins(
400401
settings.writeKey,
401402
cdnSettings,
402403
analytics,
403404
options,
404405
plugins,
405-
classicIntegrations
406+
classicIntegrations,
407+
preInitBuffer
406408
)
407409

408410
const search = window.location.search ?? ''

0 commit comments

Comments
 (0)