Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix segment ga4 bug #1080

Merged
merged 10 commits into from
Apr 29, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/brave-jokes-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@segment/analytics-core': minor
---

Do not throw errors in .register() method unless plugin has 'critical' set to true.

8 changes: 8 additions & 0 deletions .changeset/fast-moons-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@segment/analytics-next': minor
'@segment/analytics-core': minor
---

Addresses an issue where, if one of the non-destination actions fails to load/is blocked, the entire SDK fails to load. This is most notable in GA4, where, if GA was blocked, Segment initialization would fail.

In doing so, introduces a critical: true API.
57 changes: 57 additions & 0 deletions packages/browser/src/browser/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,63 @@ describe('timeout', () => {
expect(analytics.settings.timeout).toEqual(50)
})
})
describe('register', () => {
it('will not invoke any plugins that have initialization errors', async () => {
const analytics = AnalyticsBrowser.load({
writeKey,
})

const errorPlugin = new ActionDestination('Error Plugin', {
...googleAnalytics,
load: () => Promise.reject('foo'),
})
const errorPluginSpy = jest.spyOn(errorPlugin, 'track')

const goodPlugin = new ActionDestination('Good Plugin', {
...googleAnalytics,
load: () => Promise.resolve('foo'),
})
const goodPluginSpy = jest.spyOn(goodPlugin, 'track')

await analytics.register(goodPlugin, errorPlugin)
await errorPlugin.ready()
await goodPlugin.ready()

await analytics.track('foo')

expect(errorPluginSpy).not.toHaveBeenCalled()
expect(goodPluginSpy).toHaveBeenCalled()
})

it('will emit initialization errors', async () => {
const analytics = AnalyticsBrowser.load({
writeKey,
})

const errorPlugin = new ActionDestination('Error Plugin', {
...googleAnalytics,
load: () => Promise.reject('foo'),
})

const goodPlugin = new ActionDestination('Good Plugin', {
...googleAnalytics,
load: () => Promise.resolve('foo'),
})

await analytics
const errorPluginName = new Promise<string>((resolve) => {
analytics.instance?.queue.on('initialization_failure', (plugin) =>
resolve(plugin.name)
)
})

await analytics.register(goodPlugin, errorPlugin)
await errorPlugin.ready()
await goodPlugin.ready()

expect(await errorPluginName).toBe('Error Plugin')
})
})

describe('deregister', () => {
beforeEach(async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/plugins/env-enrichment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ class EnvironmentEnrichmentPlugin implements Plugin {

name = 'Page Enrichment'
type: PluginType = 'before'
critical = true
version = '0.1.0'
isLoaded = () => true
load = async (_ctx: Context, instance: Analytics) => {
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/plugins/schema-filter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export function schemaFilter(

return {
name: 'Schema Filter',
critical: true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, now that I see the load method for schema filter and env-enrichment, I'm not sure we actually need a critical field at this time. Their load methods are already safe.

It's the actual importing of the modules as part of registerPlugins that can still fail and block.

version: '0.1.0',
isLoaded: () => true,
load: () => Promise.resolve(),
Expand Down
15 changes: 5 additions & 10 deletions packages/core/src/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import type { CoreAnalytics } from '../analytics'
import type { CoreContext } from '../context'

interface CorePluginConfig {
options: any
priority: 'critical' | 'non-critical' // whether AJS should expect this plugin to be loaded before starting event delivery
}

export type PluginType =
| 'before'
| 'after'
Expand All @@ -25,12 +20,12 @@ export interface CorePlugin<
alternativeNames?: string[]
version: string
type: PluginType
/**
* if critical is set to true, will throw an error / abort the entire pipeline if the load() method throws an error.
*/
critical?: boolean
isLoaded: () => boolean
load: (
ctx: Ctx,
instance: Analytics,
config?: CorePluginConfig
) => Promise<unknown>
load: (ctx: Ctx, instance: Analytics) => Promise<unknown>

unload?: (ctx: Ctx, instance: Analytics) => Promise<unknown> | unknown
ready?: () => Promise<unknown>
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/queue/__tests__/extension-flushing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,13 @@ describe('Registration', () => {
expect(load).toHaveBeenCalledWith(ctx, ajs)
})

test('fails if plugin cant be loaded', async () => {
test('fails if plugin cant be loaded AND is type critical', async () => {
const eq = new EventQueue()

const plugin: Plugin = {
name: 'test',
type: 'before',
critical: true,
version: '0.1.0',
load: () => Promise.reject(new Error('👻')),
isLoaded: () => false,
Expand Down
44 changes: 28 additions & 16 deletions packages/core/src/queue/event-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,25 +50,37 @@ export abstract class CoreEventQueue<
plugin: Plugin,
instance: CoreAnalytics
): Promise<void> {
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,
})

// Filter out the failed plugin by excluding it from the list
this.plugins = this.plugins.filter((p) => p !== plugin)
this.plugins.push(plugin)

const handleLoadError = (err: any) => {
if (plugin.critical) {
throw 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,
})
} else {
await plugin.load(ctx, instance)

// Filter out the failed plugin by excluding it from the list
this.plugins = this.plugins.filter((p) => p !== plugin)
}

this.plugins.push(plugin)
if (plugin.type === 'destination' && plugin.name !== 'Segment.io') {
plugin.load(ctx, instance).catch(handleLoadError)
} else {
// for non-destinations plugins, we do need to wait for them to load
// reminder: action destinations can require plugins that are not of type "destination".
// For example, GA4 loads a type 'before' plugins and addition to a type 'destination' plugin
try {
await plugin.load(ctx, instance)
} catch (err) {
handleLoadError(err)
}
}
}

async deregister(
Expand Down
1 change: 0 additions & 1 deletion packages/node/src/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,6 @@ describe('version', () => {
describe('ready', () => {
it('should only resolve when plugin registration is done', async () => {
const analytics = createTestAnalytics()
expect(analytics['_queue'].plugins.length).toBe(0)
const result = await analytics.ready
expect(result).toBeUndefined()
expect(analytics['_queue'].plugins.length).toBeGreaterThan(0)
Expand Down
Loading