Skip to content

Commit

Permalink
Support adding middleware to every device mode destination (#1053)
Browse files Browse the repository at this point in the history
  • Loading branch information
silesky authored Apr 5, 2024
1 parent 3218d9e commit fd09fbc
Show file tree
Hide file tree
Showing 10 changed files with 169 additions and 37 deletions.
10 changes: 10 additions & 0 deletions .changeset/small-phones-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@segment/analytics-next': minor
---

Allow `*` in integration name field to apply middleware to all destinations plugins.
```ts
addDestinationMiddleware('*', ({ ... }) => {
...
})
```
2 changes: 1 addition & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"size-limit": [
{
"path": "dist/umd/index.js",
"limit": "29.6 KB"
"limit": "29.7 KB"
}
],
"dependencies": {
Expand Down
110 changes: 110 additions & 0 deletions packages/browser/src/browser/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,116 @@ describe('addDestinationMiddleware', () => {
})
})

it('drops events if next is never called', async () => {
const testPlugin: Plugin = {
name: 'test',
type: 'destination',
version: '0.1.0',
load: () => Promise.resolve(),
track: jest.fn(),
isLoaded: () => true,
}

const [analytics] = await AnalyticsBrowser.load({
writeKey,
})

const fullstory = new ActionDestination('fullstory', testPlugin)

await analytics.register(fullstory)
await fullstory.ready()
analytics.addDestinationMiddleware('fullstory', () => {
// do nothing
})

await analytics.track('foo')

expect(testPlugin.track).not.toHaveBeenCalled()
})

it('drops events if next is called with null', async () => {
const testPlugin: Plugin = {
name: 'test',
type: 'destination',
version: '0.1.0',
load: () => Promise.resolve(),
track: jest.fn(),
isLoaded: () => true,
}

const [analytics] = await AnalyticsBrowser.load({
writeKey,
})

const fullstory = new ActionDestination('fullstory', testPlugin)

await analytics.register(fullstory)
await fullstory.ready()
analytics.addDestinationMiddleware('fullstory', ({ next }) => {
next(null)
})

await analytics.track('foo')

expect(testPlugin.track).not.toHaveBeenCalled()
})

it('applies to all destinations if * glob is passed as name argument', async () => {
const [analytics] = await AnalyticsBrowser.load({
writeKey,
})

const p1 = new ActionDestination('p1', { ...googleAnalytics })
const p2 = new ActionDestination('p2', { ...amplitude })

await analytics.register(p1, p2)
await p1.ready()
await p2.ready()

const middleware = jest.fn()

analytics.addDestinationMiddleware('*', middleware)
await analytics.track('foo')

expect(middleware).toHaveBeenCalledTimes(2)
expect(middleware).toHaveBeenCalledWith(
expect.objectContaining({ integration: 'p1' })
)
expect(middleware).toHaveBeenCalledWith(
expect.objectContaining({ integration: 'p2' })
)
})

it('middleware is only applied to type: destination plugins', async () => {
const [analytics] = await AnalyticsBrowser.load({
writeKey,
})

const utilityPlugin = new ActionDestination('p1', {
...xt,
type: 'utility',
})

const destinationPlugin = new ActionDestination('p2', {
...xt,
type: 'destination',
})

await analytics.register(utilityPlugin, destinationPlugin)
await utilityPlugin.ready()
await destinationPlugin.ready()

const middleware = jest.fn()

analytics.addDestinationMiddleware('*', middleware)
await analytics.track('foo')

expect(middleware).toHaveBeenCalledTimes(1)
expect(middleware).toHaveBeenCalledWith(
expect.objectContaining({ integration: 'p2' })
)
})

it('supports registering action destination middlewares', async () => {
const testPlugin: Plugin = {
name: 'test',
Expand Down
13 changes: 4 additions & 9 deletions packages/browser/src/core/analytics/__tests__/test-plugins.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Context, ContextCancelation, Plugin } from '../../../index'
import type { DestinationPlugin } from '../../plugin'

export interface BasePluginOptions {
shouldThrow?: boolean
Expand Down Expand Up @@ -65,30 +64,26 @@ class BasePlugin implements Partial<Plugin> {
}
}

export class TestBeforePlugin extends BasePlugin implements Plugin {
export class TestBeforePlugin extends BasePlugin {
public name = 'Test Before Error'
public type = 'before' as const
}

export class TestEnrichmentPlugin extends BasePlugin implements Plugin {
export class TestEnrichmentPlugin extends BasePlugin {
public name = 'Test Enrichment Error'
public type = 'enrichment' as const
}

export class TestDestinationPlugin
extends BasePlugin
implements DestinationPlugin
{
export class TestDestinationPlugin extends BasePlugin {
public name = 'Test Destination Error'
public type = 'destination' as const
addMiddleware() {}

public ready() {
return Promise.resolve(true)
}
}

export class TestAfterPlugin extends BasePlugin implements Plugin {
export class TestAfterPlugin extends BasePlugin {
public name = 'Test After Error'
public type = 'after' as const
}
19 changes: 11 additions & 8 deletions packages/browser/src/core/analytics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,11 @@ import {
EventProperties,
SegmentEvent,
} from '../events'
import type { Plugin } from '../plugin'
import { isDestinationPluginWithAddMiddleware, Plugin } from '../plugin'
import { EventQueue } from '../queue/event-queue'
import { Group, ID, User, UserOptions } from '../user'
import autoBind from '../../lib/bind-all'
import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted'
import type { LegacyDestination } from '../../plugins/ajs-destination'
import type {
LegacyIntegration,
ClassicIntegrationSource,
Expand Down Expand Up @@ -520,13 +519,17 @@ export class Analytics
integrationName: string,
...middlewares: DestinationMiddlewareFunction[]
): Promise<Analytics> {
const legacyDestinations = this.queue.plugins.filter(
(xt) => xt.name.toLowerCase() === integrationName.toLowerCase()
) as LegacyDestination[]
this.queue.plugins
.filter(isDestinationPluginWithAddMiddleware)
.forEach((p) => {
if (
integrationName === '*' ||
p.name.toLowerCase() === integrationName.toLowerCase()
) {
p.addMiddleware(...middlewares)
}
})

legacyDestinations.forEach((destination) => {
destination.addMiddleware(...middlewares)
})
return Promise.resolve(this)
}

Expand Down
14 changes: 12 additions & 2 deletions packages/browser/src/core/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,18 @@ import type { Context } from '../context'

export interface Plugin extends CorePlugin<Context, Analytics> {}

export interface DestinationPlugin extends Plugin {
export interface InternalPluginWithAddMiddleware extends Plugin {
addMiddleware: (...fns: DestinationMiddlewareFunction[]) => void
}

export type AnyBrowserPlugin = Plugin | DestinationPlugin
export interface InternalDestinationPluginWithAddMiddleware
extends InternalPluginWithAddMiddleware {
type: 'destination'
}

export const isDestinationPluginWithAddMiddleware = (
plugin: Plugin
): plugin is InternalDestinationPluginWithAddMiddleware => {
// FYI: segment's plugin does not currently have an 'addMiddleware' method
return 'addMiddleware' in plugin && plugin.type === 'destination'
}
4 changes: 2 additions & 2 deletions packages/browser/src/core/queue/event-queue.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { PriorityQueue } from '../../lib/priority-queue'
import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted'
import { Context } from '../context'
import { AnyBrowserPlugin } from '../plugin'
import { Plugin } from '../plugin'
import { CoreEventQueue } from '@segment/analytics-core'
import { isOffline } from '../connection'

export class EventQueue extends CoreEventQueue<Context, AnyBrowserPlugin> {
export class EventQueue extends CoreEventQueue<Context, Plugin> {
constructor(name: string)
constructor(priorityQueue: PriorityQueue<Context>)
constructor(nameOrQueue: string | PriorityQueue<Context>) {
Expand Down
8 changes: 3 additions & 5 deletions packages/browser/src/plugins/ajs-destination/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { LegacySettings } from '../../browser'
import { isOffline, isOnline } from '../../core/connection'
import { Context, ContextCancelation } from '../../core/context'
import { isServer } from '../../core/environment'
import { DestinationPlugin, Plugin } from '../../core/plugin'
import { InternalPluginWithAddMiddleware, Plugin } from '../../core/plugin'
import { attempt } from '@segment/analytics-core'
import { isPlanEventEnabled } from '../../lib/is-plan-event-enabled'
import { mergedOptions } from '../../lib/merged-options'
Expand Down Expand Up @@ -65,12 +65,12 @@ async function flushQueue(
return queue
}

export class LegacyDestination implements DestinationPlugin {
export class LegacyDestination implements InternalPluginWithAddMiddleware {
name: string
version: string
settings: JSONObject
options: InitOptions = {}
type: Plugin['type'] = 'destination'
readonly type = 'destination'
middleware: DestinationMiddlewareFunction[] = []

private _ready: boolean | undefined
Expand Down Expand Up @@ -226,7 +226,6 @@ export class LegacyDestination implements DestinationPlugin {
type: 'Dropped by plan',
})
)
return ctx
} else {
ctx.updateEvent('integrations', {
...ctx.event.integrations,
Expand All @@ -242,7 +241,6 @@ export class LegacyDestination implements DestinationPlugin {
type: 'Dropped by plan',
})
)
return ctx
}
}

Expand Down
16 changes: 8 additions & 8 deletions packages/browser/src/plugins/remote-loader/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Integrations } from '../../core/events/interfaces'
import { LegacySettings } from '../../browser'
import { JSONObject, JSONValue } from '../../core/events'
import { DestinationPlugin, Plugin } from '../../core/plugin'
import { Plugin, InternalPluginWithAddMiddleware } from '../../core/plugin'
import { loadScript } from '../../lib/load-script'
import { getCDN } from '../../lib/parse-cdn'
import {
Expand All @@ -26,9 +26,13 @@ export interface RemotePlugin {
settings: JSONObject
}

export class ActionDestination implements DestinationPlugin {
export class ActionDestination implements InternalPluginWithAddMiddleware {
name: string // destination name
version = '1.0.0'
/**
* The lifecycle name of the wrapped plugin.
* This does not need to be 'destination', and can be 'enrichment', etc.
*/
type: Plugin['type']

alternativeNames: string[] = []
Expand All @@ -47,6 +51,7 @@ export class ActionDestination implements DestinationPlugin {
}

addMiddleware(...fn: DestinationMiddlewareFunction[]): void {
/** Make sure we only apply destination filters to actions of the "destination" type to avoid causing issues for hybrid destinations */
if (this.type === 'destination') {
this.middleware.push(...fn)
}
Expand Down Expand Up @@ -289,12 +294,7 @@ export async function remoteLoader(
plugin
)

/** Make sure we only apply destination filters to actions of the "destination" type to avoid causing issues for hybrid destinations */
if (
routing.length &&
routingMiddleware &&
plugin.type === 'destination'
) {
if (routing.length && routingMiddleware) {
wrapper.addMiddleware(routingMiddleware)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,22 @@ export const createMockFetchImplementation = (
) => {
return (...[url, req]: Parameters<typeof fetch>) => {
const reqUrl = url.toString()
if (!req || (req.method === 'get' && reqUrl.includes('cdn.segment.com'))) {
const reqMethod = req?.method?.toLowerCase()
if (!req || (reqMethod === 'get' && reqUrl.includes('cdn.segment.com'))) {
// GET https://cdn.segment.com/v1/projects/{writeKey}
return createSuccess({ ...cdnSettingsMinimal, ...cdnSettings })
}

if (req?.method === 'post' && reqUrl.includes('api.segment.io')) {
if (reqMethod === 'post' && reqUrl.includes('api.segment.io')) {
// POST https://api.segment.io/v1/{event.type}
return createSuccess({ success: true }, { status: 201 })
}

if (reqMethod === 'post' && reqUrl.endsWith('/m')) {
// POST https://api.segment.io/m
return createSuccess({ success: true })
}

throw new Error(
`no match found for request (url:${url}, req:${JSON.stringify(req)})`
)
Expand Down

0 comments on commit fd09fbc

Please sign in to comment.