From 61688e251ad2f60dae4cfd65cf59401c29ec66bd Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Wed, 14 Dec 2022 16:03:29 -0600 Subject: [PATCH] Refactor node / browser to share Context, EventQueue, attempt(), Stats, dispatch(), Plugins, and update traits and context interface (#722) --- .changeset/sweet-rings-tie.md | 7 + .eslintrc.js | 1 + examples/standalone-playground/package.json | 6 +- package.json | 7 +- .../browser-integration-tests/package.json | 1 + packages/browser/package.json | 3 +- .../analytics-pre-init.integration.test.ts | 2 + .../__tests__/standalone-analytics.test.ts | 2 +- .../typedef-tests/analytics-browser.ts | 21 +- packages/browser/src/browser/index.ts | 2 +- .../analytics/__tests__/integration.test.ts | 24 +- .../core/analytics/__tests__/test-plugins.ts | 13 +- packages/browser/src/core/analytics/index.ts | 53 +- .../browser/src/core/analytics/interfaces.ts | 3 +- .../src/core/callback/__tests__/index.test.ts | 85 -- packages/browser/src/core/callback/index.ts | 53 +- .../src/core/context/__tests__/index.test.ts | 4 + packages/browser/src/core/context/index.ts | 155 +--- .../browser/src/core/events/interfaces.ts | 252 +----- packages/browser/src/core/inspector/index.ts | 8 +- .../src/core/logger/__tests__/index.test.ts | 66 -- packages/browser/src/core/logger/index.ts | 71 -- packages/browser/src/core/plugin/index.ts | 42 +- .../core/query-string/__tests__/index.test.ts | 4 +- .../core/queue/__tests__/event-queue.test.ts | 781 +----------------- .../__tests__/extension-flushing.test.ts | 406 --------- packages/browser/src/core/queue/delivery.ts | 78 -- .../browser/src/core/queue/event-queue.ts | 314 +------ .../src/core/stats/__tests__/index.test.ts | 100 +-- .../stats/__tests__/remote-metrics.test.ts | 78 +- packages/browser/src/core/stats/index.ts | 93 +-- .../browser/src/core/stats/remote-metrics.ts | 10 +- packages/browser/src/core/task/task-group.ts | 31 - .../src/core/user/__tests__/index.test.ts | 2 + .../browser/src/lib/priority-queue/index.ts | 100 +-- .../src/plugins/ajs-destination/index.ts | 6 +- .../src/plugins/remote-loader/index.ts | 4 +- .../segmentio/__tests__/normalize.test.ts | 4 +- .../segmentio/__tests__/retries.test.ts | 40 +- .../src/plugins/segmentio/schedule-flush.ts | 2 +- packages/browser/webpack.config.js | 2 + .../src/public-api.test.ts | 4 +- packages/core/package.json | 2 +- .../src/analytics/__tests__/dispatch.test.ts | 53 +- packages/core/src/analytics/dispatch.ts | 30 +- .../core/src/callback/__tests__/index.test.ts | 14 +- packages/core/src/callback/index.ts | 10 +- packages/core/src/context/index.ts | 24 +- packages/core/src/events/interfaces.ts | 14 +- packages/core/src/index.ts | 2 + packages/core/src/logger/index.ts | 7 +- packages/core/src/plugins/index.ts | 10 +- packages/core/src/priority-queue/index.ts | 55 +- .../src/queue/__tests__/event-queue.test.ts | 149 ++-- .../__tests__/extension-flushing.test.ts | 51 +- packages/core/src/queue/delivery.ts | 35 +- packages/core/src/queue/event-queue.ts | 89 +- .../core/src/stats/__tests__/index.test.ts | 103 +++ packages/core/src/stats/index.ts | 35 +- packages/core/src/stats/remote-metrics.ts | 16 - packages/core/test-helpers/index.ts | 2 + packages/core/test-helpers/test-ctx.ts | 7 + .../core/test-helpers/test-event-queue.ts | 7 + packages/node-integration-tests/package.json | 2 - packages/node/package.json | 2 +- packages/node/src/__tests__/callback.test.ts | 2 +- .../graceful-shutdown-integration.test.ts | 12 +- .../node/src/__tests__/integration.test.ts | 2 +- .../src/__tests__/test-helpers/resolve-ctx.ts | 3 +- .../src/__tests__/test-helpers/test-plugin.ts | 4 +- packages/node/src/app/analytics-node.ts | 25 +- packages/node/src/app/context.ts | 11 + packages/node/src/app/dispatch-emit.ts | 19 +- packages/node/src/app/emitter.ts | 2 +- packages/node/src/app/event-factory.ts | 12 + packages/node/src/app/event-queue.ts | 7 +- packages/node/src/app/types/plugin.ts | 4 +- packages/node/src/index.ts | 3 +- .../plugins/segmentio/__tests__/index.test.ts | 42 +- .../src/plugins/segmentio/context-batch.ts | 13 +- packages/node/src/plugins/segmentio/index.ts | 10 +- .../node/src/plugins/segmentio/publisher.ts | 11 +- yarn.lock | 63 +- 83 files changed, 810 insertions(+), 3094 deletions(-) create mode 100644 .changeset/sweet-rings-tie.md delete mode 100644 packages/browser/src/core/callback/__tests__/index.test.ts delete mode 100644 packages/browser/src/core/logger/__tests__/index.test.ts delete mode 100644 packages/browser/src/core/logger/index.ts delete mode 100644 packages/browser/src/core/queue/__tests__/extension-flushing.test.ts delete mode 100644 packages/browser/src/core/queue/delivery.ts delete mode 100644 packages/browser/src/core/task/task-group.ts create mode 100644 packages/core/src/stats/__tests__/index.test.ts delete mode 100644 packages/core/src/stats/remote-metrics.ts create mode 100644 packages/core/test-helpers/index.ts create mode 100644 packages/core/test-helpers/test-ctx.ts create mode 100644 packages/core/test-helpers/test-event-queue.ts create mode 100644 packages/node/src/app/context.ts diff --git a/.changeset/sweet-rings-tie.md b/.changeset/sweet-rings-tie.md new file mode 100644 index 000000000..8b2d7dcdb --- /dev/null +++ b/.changeset/sweet-rings-tie.md @@ -0,0 +1,7 @@ +--- +'@segment/analytics-next': minor +'@segment/analytics-core': minor +--- + +Improve core interfaces. Refactor analytics-next to use shared EventQueue, dispatch, and other methods. +Augment Browser interface with traits and context options type. diff --git a/.eslintrc.js b/.eslintrc.js index 9a7b0f8dd..15df73ad8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -49,6 +49,7 @@ module.exports = { '@typescript-eslint/ban-ts-ignore': 'off', '@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-empty-interface': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-floating-promises': [ diff --git a/examples/standalone-playground/package.json b/examples/standalone-playground/package.json index 0d4aadb6d..0ee2d0108 100644 --- a/examples/standalone-playground/package.json +++ b/examples/standalone-playground/package.json @@ -5,12 +5,10 @@ "hoistingLimits": "workspaces" }, "scripts": { - "dev": "yarn concurrently 'yarn run -T watch --filter=standalone-playground' 'sleep 10 && npx http-server .'" + "dev": "yarn concurrently 'yarn run -T watch --filter=standalone-playground' 'sleep 10 && npx http-server .'", + "concurrently": "yarn run -T concurrently" }, "dependencies": { "@segment/analytics-next": "workspace:^" - }, - "devDependencies": { - "concurrently": "^7.2.1" } } diff --git a/package.json b/package.json index 5dbe5f45f..6f9a5260f 100644 --- a/package.json +++ b/package.json @@ -36,15 +36,17 @@ "@changesets/cli": "^2.23.2", "@internal/config": "workspace:^", "@npmcli/promise-spawn": "^3.0.0", + "@types/express": "4", "@types/jest": "^28.1.1", "@types/lodash": "^4", "@types/node-fetch": "^2.6.2", "@typescript-eslint/eslint-plugin": "^5.21.0", "@typescript-eslint/parser": "^5.21.0", - "concurrently": "^7.2.1", + "concurrently": "^7.6.0", "eslint": "^8.14.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.0.0", + "express": "^4.18.2", "get-monorepo-packages": "^1.2.0", "husky": "^8.0.0", "jest": "^28.1.0", @@ -60,6 +62,7 @@ }, "resolutions": { "@segment/analytics-next": "workspace:*", - "@segment/analytics-node": "workspace:*" + "@segment/analytics-node": "workspace:*", + "@segment/analytics-core": "workspace:*" } } diff --git a/packages/browser-integration-tests/package.json b/packages/browser-integration-tests/package.json index 0795a833b..b413b2702 100644 --- a/packages/browser-integration-tests/package.json +++ b/packages/browser-integration-tests/package.json @@ -8,6 +8,7 @@ "scripts": { "test": "playwright test", "lint": "yarn concurrently 'yarn:eslint .' 'yarn:tsc --noEmit'", + "concurrently": "yarn run -T concurrently", "watch:test": "yarn test --watch", "tsc": "yarn run -T tsc", "eslint": "yarn run -T eslint", diff --git a/packages/browser/package.json b/packages/browser/package.json index 54f194056..cbc9c768a 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -31,7 +31,7 @@ "tsc": "yarn run -T tsc", "jest": "yarn run -T jest", "concurrently": "yarn run -T concurrently", - "watch": "yarn concurrently 'NODE_ENV=production yarn umd --watch' 'yarn pkg --watch --incremental'", + "watch": "yarn concurrently 'NODE_ENV=production WATCH=true yarn umd --watch' 'yarn pkg --watch'", "build": "yarn clean && yarn build-prep && yarn concurrently 'NODE_ENV=production yarn umd' 'yarn pkg' 'yarn cjs'", "release:cdn": "yarn run -T browser+deps build && NODE_ENV=production bash scripts/release.sh", "pkg": "yarn tsc -p tsconfig.build.json", @@ -79,7 +79,6 @@ "circular-dependency-plugin": "^5.2.2", "compression-webpack-plugin": "^8.0.1", "execa": "^4.1.0", - "express": "^4.17.3", "flat": "^5.0.2", "fs-extra": "^9.0.1", "jest-dev-server": "^6.0.3", diff --git a/packages/browser/src/browser/__tests__/analytics-pre-init.integration.test.ts b/packages/browser/src/browser/__tests__/analytics-pre-init.integration.test.ts index 3e0fdd054..612d82c61 100644 --- a/packages/browser/src/browser/__tests__/analytics-pre-init.integration.test.ts +++ b/packages/browser/src/browser/__tests__/analytics-pre-init.integration.test.ts @@ -23,6 +23,8 @@ const writeKey = 'foo' const errMsg = 'errMsg' +jest.spyOn(console, 'error').mockImplementation(() => {}) // silence console spam + describe('Pre-initialization', () => { const trackSpy = jest.spyOn(Analytics.prototype, 'track') const identifySpy = jest.spyOn(Analytics.prototype, 'identify') diff --git a/packages/browser/src/browser/__tests__/standalone-analytics.test.ts b/packages/browser/src/browser/__tests__/standalone-analytics.test.ts index 3b4b39840..3ba23c309 100644 --- a/packages/browser/src/browser/__tests__/standalone-analytics.test.ts +++ b/packages/browser/src/browser/__tests__/standalone-analytics.test.ts @@ -7,7 +7,7 @@ import unfetch from 'unfetch' import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted' import { sleep } from '../../lib/sleep' import * as Factory from '../../test-helpers/factories' -import { EventQueue } from '@segment/analytics-core' +import { EventQueue } from '../../core/queue/event-queue' const track = jest.fn() const identify = jest.fn() diff --git a/packages/browser/src/browser/__tests__/typedef-tests/analytics-browser.ts b/packages/browser/src/browser/__tests__/typedef-tests/analytics-browser.ts index 23bb20d82..6b4607e38 100644 --- a/packages/browser/src/browser/__tests__/typedef-tests/analytics-browser.ts +++ b/packages/browser/src/browser/__tests__/typedef-tests/analytics-browser.ts @@ -1,8 +1,5 @@ -import { Analytics } from '@/core/analytics' -import { Context } from '@/core/context' -import { AnalyticsBrowser } from '@/browser' -import { assertNotAny, assertIs } from '@/test-helpers/type-assertions' -import { Group, User } from '../../../core/user' +import { AnalyticsBrowser, Analytics, Context, User, Group } from '../../..' +import { assertNotAny, assertIs } from '../../../test-helpers/type-assertions' /** * These are general typescript definition tests; @@ -77,7 +74,7 @@ export default { 'Identify should work with spread objects ': () => { const user = { name: 'john', - id: 12345, + id: 'abc123', } const { id, ...traits } = user void AnalyticsBrowser.load({ writeKey: 'foo' }).identify('foo', traits) @@ -85,7 +82,7 @@ export default { 'Track should work with spread objects': () => { const user = { name: 'john', - id: 12345, + id: 'abc123', } const { id, ...traits } = user void AnalyticsBrowser.load({ writeKey: 'foo' }).track('foo', traits) @@ -93,7 +90,7 @@ export default { 'Identify should work with generic objects ': () => { const user = { name: 'john', - id: 12345, + id: 'abc123', } void AnalyticsBrowser.load({ writeKey: 'foo' }).identify('foo', user) }, @@ -122,4 +119,12 @@ export default { assertNotAny(analytics) assertIs(analytics) }, + 'Should error if there is a type conflict in Traits': () => { + const analytics = new AnalyticsBrowser().load({ writeKey: 'foo' }) + assertNotAny(analytics) + assertIs(analytics) + + // @ts-expect-error - id should be a string + void analytics.identify('foo', { id: 123 }) + }, } diff --git a/packages/browser/src/browser/index.ts b/packages/browser/src/browser/index.ts index a3b968662..947d6241d 100644 --- a/packages/browser/src/browser/index.ts +++ b/packages/browser/src/browser/index.ts @@ -279,7 +279,7 @@ async function loadAnalytics( const plugins = settings.plugins ?? [] const classicIntegrations = settings.classicIntegrations ?? [] - Context.initMetrics(legacySettings.metrics) + Context.initRemoteMetrics(legacySettings.metrics) // needs to be flushed before plugins are registered flushPreBuffer(analytics, preInitBuffer) diff --git a/packages/browser/src/core/analytics/__tests__/integration.test.ts b/packages/browser/src/core/analytics/__tests__/integration.test.ts index 245127852..64711a803 100644 --- a/packages/browser/src/core/analytics/__tests__/integration.test.ts +++ b/packages/browser/src/core/analytics/__tests__/integration.test.ts @@ -9,10 +9,10 @@ import { Plugin } from '../../plugin' import { EventQueue } from '../../queue/event-queue' import { Analytics } from '../index' import { - AfterPlugin, - BeforePlugin, - DestinationPlugin, - EnrichmentPlugin, + TestAfterPlugin, + TestBeforePlugin, + TestDestinationPlugin, + TestEnrichmentPlugin, } from './test-plugins' describe('Analytics', () => { @@ -34,7 +34,7 @@ describe('Analytics', () => { const shouldThrow = true it(`"before" plugin errors should not throw (single dispatched event)`, async () => { - const plugin = new BeforePlugin({ shouldThrow }) + const plugin = new TestBeforePlugin({ shouldThrow }) const trackSpy = jest.spyOn(plugin, 'track') await analytics.register(plugin) @@ -45,7 +45,7 @@ describe('Analytics', () => { }) it(`"before" plugin errors should not throw (multiple dispatched events)`, async () => { - const plugin = new BeforePlugin({ shouldThrow }) + const plugin = new TestBeforePlugin({ shouldThrow }) const trackSpy = jest.spyOn(plugin, 'track') await analytics.register(plugin) @@ -63,7 +63,7 @@ describe('Analytics', () => { }) it(`"before" plugin errors should not impact callbacks`, async () => { - const plugin = new BeforePlugin({ shouldThrow }) + const plugin = new TestBeforePlugin({ shouldThrow }) const trackSpy = jest.spyOn(plugin, 'track') await analytics.register(plugin) @@ -102,9 +102,9 @@ describe('Analytics', () => { } const testPlugins = [ - new EnrichmentPlugin({ shouldThrow }), - new DestinationPlugin({ shouldThrow }), - new AfterPlugin({ shouldThrow }), + new TestEnrichmentPlugin({ shouldThrow }), + new TestDestinationPlugin({ shouldThrow }), + new TestAfterPlugin({ shouldThrow }), ] testPlugins.forEach((plugin) => { it(`"${plugin.type}" plugin errors should not throw (single dispatched event)`, async () => { @@ -152,7 +152,7 @@ describe('Analytics', () => { }) it('"before" plugin supports cancelation (single dispatched event)', async () => { - const plugin = new BeforePlugin({ shouldCancel: true }) + const plugin = new TestBeforePlugin({ shouldCancel: true }) const trackSpy = jest.spyOn(plugin, 'track') await analytics.register(plugin) @@ -164,7 +164,7 @@ describe('Analytics', () => { }) it('"before" plugin supports cancelation (multiple dispatched events)', async () => { - const plugin = new BeforePlugin({ shouldCancel: true }) + const plugin = new TestBeforePlugin({ shouldCancel: true }) const trackSpy = jest.spyOn(plugin, 'track') await analytics.register(plugin) diff --git a/packages/browser/src/core/analytics/__tests__/test-plugins.ts b/packages/browser/src/core/analytics/__tests__/test-plugins.ts index 847db3ca9..bbc189993 100644 --- a/packages/browser/src/core/analytics/__tests__/test-plugins.ts +++ b/packages/browser/src/core/analytics/__tests__/test-plugins.ts @@ -1,4 +1,5 @@ import { Context, ContextCancelation, Plugin } from '../../../index' +import type { DestinationPlugin } from '../../plugin' export interface BasePluginOptions { shouldThrow?: boolean @@ -64,26 +65,30 @@ class BasePlugin implements Partial { } } -export class BeforePlugin extends BasePlugin implements Plugin { +export class TestBeforePlugin extends BasePlugin implements Plugin { public name = 'Test Before Error' public type = 'before' as const } -export class EnrichmentPlugin extends BasePlugin implements Plugin { +export class TestEnrichmentPlugin extends BasePlugin implements Plugin { public name = 'Test Enrichment Error' public type = 'enrichment' as const } -export class DestinationPlugin extends BasePlugin implements Plugin { +export class TestDestinationPlugin + extends BasePlugin + implements DestinationPlugin +{ public name = 'Test Destination Error' public type = 'destination' as const + addMiddleware() {} public ready() { return Promise.resolve(true) } } -export class AfterPlugin extends BasePlugin implements Plugin { +export class TestAfterPlugin extends BasePlugin implements Plugin { public name = 'Test After Error' public type = 'after' as const } diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts index fbfe42f1f..60f1b764d 100644 --- a/packages/browser/src/core/analytics/index.ts +++ b/packages/browser/src/core/analytics/index.ts @@ -10,10 +10,9 @@ import { UserParams, } from '../arguments-resolver' import type { FormArgs, LinkArgs } from '../auto-track' -import { invokeCallback } from '../callback' import { isOffline } from '../connection' import { Context } from '../context' -import { Emitter } from '@segment/analytics-core' +import { dispatch, Emitter } from '@segment/analytics-core' import { Callback, EventFactory, @@ -22,7 +21,7 @@ import { EventProperties, SegmentEvent, } from '../events' -import { Plugin } from '../plugin' +import type { Plugin } from '../plugin' import { EventQueue } from '../queue/event-queue' import { CookieOptions, @@ -179,7 +178,7 @@ export class Analytics this.integrations ) - return this.dispatch(segmentEvent, cb).then((ctx) => { + return this._dispatch(segmentEvent, cb).then((ctx) => { this.emit('track', name, ctx.event.properties, ctx.event.options) return ctx }) @@ -197,7 +196,7 @@ export class Analytics this.integrations ) - return this.dispatch(segmentEvent, callback).then((ctx) => { + return this._dispatch(segmentEvent, callback).then((ctx) => { this.emit('page', category, page, ctx.event.properties, ctx.event.options) return ctx }) @@ -216,7 +215,7 @@ export class Analytics this.integrations ) - return this.dispatch(segmentEvent, callback).then((ctx) => { + return this._dispatch(segmentEvent, callback).then((ctx) => { this.emit( 'identify', ctx.event.userId, @@ -249,7 +248,7 @@ export class Analytics this.integrations ) - return this.dispatch(segmentEvent, callback).then((ctx) => { + return this._dispatch(segmentEvent, callback).then((ctx) => { this.emit('group', ctx.event.groupId, ctx.event.traits, ctx.event.options) return ctx }) @@ -263,7 +262,7 @@ export class Analytics options, this.integrations ) - return this.dispatch(segmentEvent, callback).then((ctx) => { + return this._dispatch(segmentEvent, callback).then((ctx) => { this.emit('alias', to, from, ctx.event.options) return ctx }) @@ -280,7 +279,7 @@ export class Analytics options, this.integrations ) - return this.dispatch(segmentEvent, callback).then((ctx) => { + return this._dispatch(segmentEvent, callback).then((ctx) => { this.emit( 'screen', category, @@ -334,7 +333,7 @@ export class Analytics async deregister(...plugins: string[]): Promise { const ctx = Context.system() - const deregistrations = plugins.map(async (pl) => { + const deregistrations = plugins.map((pl) => { const plugin = this.queue.plugins.find((p) => p.name === pl) if (plugin) { return this.queue.deregister(ctx, plugin, this) @@ -367,41 +366,19 @@ export class Analytics this.settings.timeout = timeout } - private async dispatch( + private async _dispatch( event: SegmentEvent, callback?: Callback ): Promise { const ctx = new Context(event) - - this.emit('dispatch_start', ctx) - if (isOffline() && !this.options.retryQueue) { return ctx } - - const startTime = Date.now() - let dispatched: Context - if (this.queue.isEmpty()) { - dispatched = await this.queue.dispatchSingle(ctx) - } else { - dispatched = await this.queue.dispatch(ctx) - } - const elapsedTime = Date.now() - startTime - const timeoutInMs = this.settings.timeout - - if (callback) { - dispatched = await invokeCallback( - dispatched, - callback, - Math.max((timeoutInMs ?? 300) - elapsedTime, 0), - timeoutInMs - ) - } - if (this._debug) { - dispatched.flush() - } - - return dispatched + return dispatch(ctx, this.queue, this, { + callback, + debug: this._debug, + timeout: this.settings.timeout, + }) } async addSourceMiddleware(fn: MiddlewareFunction): Promise { diff --git a/packages/browser/src/core/analytics/interfaces.ts b/packages/browser/src/core/analytics/interfaces.ts index 45388a35d..08a642862 100644 --- a/packages/browser/src/core/analytics/interfaces.ts +++ b/packages/browser/src/core/analytics/interfaces.ts @@ -11,6 +11,7 @@ import type { Context } from '../context' import type { SegmentEvent } from '../events' import type { Group, User } from '../user' import type { LegacyIntegration } from '../../plugins/ajs-destination/types' +import { CoreAnalytics } from '@segment/analytics-core' // we can define a contract because: // - it gives us a neat place to put all our typedocs (they end up being inherited by the class that implements them). @@ -74,7 +75,7 @@ export interface AnalyticsClassic extends AnalyticsClassicStubs { /** * Interface implemented by concrete Analytics class (commonly accessible if you use "await" on AnalyticsBrowser.load()) */ -export type AnalyticsCore = { +export interface AnalyticsCore extends CoreAnalytics { track(...args: EventParams): Promise page(...args: PageParams): Promise identify(...args: UserParams): Promise diff --git a/packages/browser/src/core/callback/__tests__/index.test.ts b/packages/browser/src/core/callback/__tests__/index.test.ts deleted file mode 100644 index d89a3576f..000000000 --- a/packages/browser/src/core/callback/__tests__/index.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { invokeCallback } from '..' -import { Context } from '../../context' - -describe(invokeCallback, () => { - afterEach(() => { - jest.useRealTimers() - }) - - it('invokes a callback asynchronously', async () => { - const ctx = new Context({ - type: 'track', - }) - - const fn = jest.fn() - const returned = await invokeCallback(ctx, fn, 0) - - expect(fn).toHaveBeenCalledWith(ctx) - expect(returned).toBe(ctx) - }) - - // Fixes GitHub issue: https://github.com/segmentio/analytics-next/issues/409 - // A.JS classic waited for the timeout before invoking callback, - // so keep same behavior in A.JS next. - it('calls the callback after a timeout', async () => { - const ctx = new Context({ - type: 'track', - }) - - const fn = jest.fn() - const timeout = 100 - - const startTime = Date.now() - const returned = await invokeCallback(ctx, fn, timeout) - const endTime = Date.now() - - expect(fn).toHaveBeenCalled() - expect(endTime - startTime).toBeGreaterThanOrEqual(timeout - 1) - expect(returned).toBe(ctx) - }) - - it('ignores the callback after a timeout', async () => { - const ctx = new Context({ - type: 'track', - }) - - const slow = (_ctx: Context): Promise => { - return new Promise((resolve) => { - setTimeout(resolve, 200) - }) - } - - const returned = await invokeCallback(ctx, slow, 0, 50) - expect(returned).toBe(ctx) - - const logs = returned.logs() - expect(logs[0].extras).toMatchInlineSnapshot(` - Object { - "error": [Error: Promise timed out], - } - `) - - expect(logs[0].level).toEqual('warn') - }) - - it('does not crash if the callback crashes', async () => { - const ctx = new Context({ - type: 'track', - }) - - const boo = (_ctx: Context): Promise => { - throw new Error('👻 boo!') - } - - const returned = await invokeCallback(ctx, boo, 0) - expect(returned).toBe(ctx) - - const logs = returned.logs() - expect(logs[0].extras).toMatchInlineSnapshot(` - Object { - "error": [Error: 👻 boo!], - } - `) - expect(logs[0].level).toEqual('warn') - }) -}) diff --git a/packages/browser/src/core/callback/index.ts b/packages/browser/src/core/callback/index.ts index 9c52aa189..c162063b1 100644 --- a/packages/browser/src/core/callback/index.ts +++ b/packages/browser/src/core/callback/index.ts @@ -1,52 +1 @@ -import { Context } from '../context' -import { Callback } from '../events/interfaces' - -export function pTimeout( - cb: Promise, - timeout: number -): Promise { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - reject(Error('Promise timed out')) - }, timeout) - - cb.then((val) => { - clearTimeout(timeoutId) - return resolve(val) - }).catch(reject) - }) -} - -function sleep(timeoutInMs: number): Promise { - return new Promise((resolve) => setTimeout(resolve, timeoutInMs)) -} - -/** - * @param delayTimeout - The amount of time in ms to wait before invoking the callback. - * @param timeout - The maximum amount of time in ms to allow the callback to run for. - */ -export function invokeCallback( - ctx: Context, - callback: Callback, - delayTimeout: number, - timeout?: number -): Promise { - const cb = () => { - try { - return Promise.resolve(callback(ctx)) - } catch (err) { - return Promise.reject(err) - } - } - - return ( - sleep(delayTimeout) - // pTimeout ensures that the callback can't cause the context to hang - .then(() => pTimeout(cb(), timeout ?? 1000)) - .catch((err) => { - ctx?.log('warn', 'Callback Error', { error: err }) - ctx?.stats.increment('callback_error') - }) - .then(() => ctx) - ) -} +export { invokeCallback, pTimeout } from '@segment/analytics-core' diff --git a/packages/browser/src/core/context/__tests__/index.test.ts b/packages/browser/src/core/context/__tests__/index.test.ts index 5fa630332..744ce96f3 100644 --- a/packages/browser/src/core/context/__tests__/index.test.ts +++ b/packages/browser/src/core/context/__tests__/index.test.ts @@ -3,6 +3,10 @@ import { Context } from '..' import { SegmentEvent } from '../../events' describe(Context, () => { + // hide console spam when running tests + jest.spyOn(console, 'log').mockImplementation(() => {}) + jest.spyOn(console, 'table').mockImplementation(() => {}) + describe('.system', () => { it('creates a system event', () => { const ctx = Context.system() diff --git a/packages/browser/src/core/context/index.ts b/packages/browser/src/core/context/index.ts index 7c7bbbf90..5b2b917f6 100644 --- a/packages/browser/src/core/context/index.ts +++ b/packages/browser/src/core/context/index.ts @@ -1,143 +1,26 @@ -import { v4 as uuid } from '@lukeed/uuid' -import { dset } from 'dset' -import { SegmentEvent } from '../events' -import Logger, { LogLevel, LogMessage } from '../logger' -import Stats, { Metric } from '../stats' +import { + CoreContext, + ContextCancelation, + ContextFailedDelivery, + SerializedContext, + CancelationOptions, +} from '@segment/analytics-core' +import { SegmentEvent } from '../events/interfaces' +import { Stats } from '../stats' import { MetricsOptions, RemoteMetrics } from '../stats/remote-metrics' -export interface AbstractContext { - cancel: () => never - log: (level: LogLevel, message: string, extras?: object) => void - stats: Stats -} - -export interface SerializedContext { - id: string - event: SegmentEvent - logs: LogMessage[] - metrics: Metric[] -} - -interface CancelationOptions { - retry?: boolean - reason?: string - type?: string -} - -export interface ContextFailedDelivery { - reason: unknown -} - -export class ContextCancelation { - retry: boolean - type: string - reason?: string - - constructor(options: CancelationOptions) { - this.retry = options.retry ?? true - this.type = options.type ?? 'plugin Error' - this.reason = options.reason ?? '' - } -} - -let remoteMetrics: RemoteMetrics | undefined - -export class Context implements AbstractContext { - private _event: SegmentEvent - private _attempts: number - public logger = new Logger() - public stats: Stats - private _id: string - private _failedDelivery?: ContextFailedDelivery - - constructor(event: SegmentEvent, id?: string) { - this._attempts = 0 - this._event = event - this._id = id ?? uuid() - this.stats = new Stats(remoteMetrics) - } - - static initMetrics(options?: MetricsOptions): void { - remoteMetrics = new RemoteMetrics(options) - } - - static system(): Context { - return new Context({ type: 'track', event: 'system' }) - } - - isSame(other: Context): boolean { - return other._id === this._id - } - - cancel = (error?: Error | ContextCancelation): never => { - if (error) { - throw error - } - - throw new ContextCancelation({ reason: 'Context Cancel' }) - } - - log(level: LogLevel, message: string, extras?: object): void { - this.logger.log(level, message, extras) - } - - public get id(): string { - return this._id - } - - public get event(): SegmentEvent { - return this._event - } - - public set event(evt: SegmentEvent) { - this._event = evt - } - - public get attempts(): number { - return this._attempts - } - - public set attempts(attempts: number) { - this._attempts = attempts - } - - public updateEvent(path: string, val: unknown): SegmentEvent { - // Don't allow integrations that are set to false to be overwritten with integration settings. - if (path.split('.')[0] === 'integrations') { - const integrationName = path.split('.')[1] - - if (this._event.integrations?.[integrationName] === false) { - return this._event - } - } - - dset(this._event, path, val) - return this._event - } - - public failedDelivery(): ContextFailedDelivery | undefined { - return this._failedDelivery - } - - public setFailedDelivery(options: ContextFailedDelivery) { - this._failedDelivery = options - } +let _remoteMetrics: RemoteMetrics - public logs(): LogMessage[] { - return this.logger.logs +export class Context extends CoreContext { + static override system() { + return new this({ type: 'track', event: 'system' }) } - - public flush(): void { - this.logger.flush() - this.stats.flush() + static initRemoteMetrics(options?: MetricsOptions) { + _remoteMetrics = new RemoteMetrics(options) } - - toJSON(): SerializedContext { - return { - id: this._id, - event: this._event, - logs: this.logger.logs, - metrics: this.stats.metrics, - } + constructor(event: SegmentEvent, id?: string) { + super(event, id, new Stats(_remoteMetrics)) } } +export { ContextCancelation } +export type { ContextFailedDelivery, SerializedContext, CancelationOptions } diff --git a/packages/browser/src/core/events/interfaces.ts b/packages/browser/src/core/events/interfaces.ts index a5c32f377..fbcd70164 100644 --- a/packages/browser/src/core/events/interfaces.ts +++ b/packages/browser/src/core/events/interfaces.ts @@ -1,229 +1,35 @@ -import { Context } from '../context' -import { CompactMetric } from '../stats' -import { ID } from '../user' - -export type JSONPrimitive = string | number | boolean | null -export type JSONValue = JSONPrimitive | JSONObject | JSONArray -export type JSONObject = { [member: string]: JSONValue } -export type JSONArray = JSONValue[] - -export type Callback = (ctx: Context) => Promise | unknown - -export type Integrations = { - All?: boolean - [integration: string]: boolean | JSONObject | undefined -} - -export type Options = { - integrations?: Integrations - anonymousId?: ID - timestamp?: Date | string - context?: AnalyticsContext - traits?: Traits - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any -} - -interface AnalyticsContext { - page?: { - /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L151} - */ - path?: string - referrer?: string - search?: string - title?: string - url?: string - } - metrics?: CompactMetric[] - - /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L285} - */ - userAgent?: string - - /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L286-L289} - */ - locale?: string - - /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L290-L291} - */ - library?: { - name: string - version: string - } - - /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L292-L301} - */ - traits?: { - crossDomainId?: string - } - - /** - * utm params - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L303-L305} - * {@link https://github.com/segmentio/utm-params/blob/master/lib/index.js#L49} - */ - campaign?: { - /** - * This can also come from the "utm_campaign" param - * - * {@link https://github.com/segmentio/utm-params/blob/master/lib/index.js#L40} - */ - name: string - term: string - source: string - medium: string - content: string - } - - /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L415} - */ - referrer?: { - btid?: string - urid?: string - } - - /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L322} - */ - amp?: { - id: string - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any -} +import { + CoreAnalyticsTraits, + CoreOptions, + CoreSegmentEvent, + Callback, + Integrations, + Plan, + TrackPlan, + PlanEvent, + JSONArray, + JSONValue, + JSONPrimitive, + JSONObject, +} from '@segment/analytics-core' + +export interface Options extends CoreOptions {} // This is not ideal, but it works with all the edge cases -export type Traits = Record +export interface Traits extends CoreAnalyticsTraits {} export type EventProperties = Record -export interface SegmentEvent { - messageId?: string - - type: 'track' | 'page' | 'identify' | 'group' | 'alias' | 'screen' - - // page specific - category?: string - name?: string - /** - * An object literal representing Segment event properties - * - track: https://segment.com/docs/connections/spec/track/#properties - * - page: https://segment.com/docs/connections/spec/page/#properties - * - screen: https://segment.com/docs/connections/spec/screen/#properties - * @example - * { artistID: 2435325, songID: 13532532 } - */ - properties?: EventProperties - /** - * An object literal representing traits - * - identify: https://segment.com/docs/connections/spec/identify/#traits - * - group: https://segment.com/docs/connections/spec/group/#traits - * @example - * { name: "john", age: 25 } - */ - traits?: Traits - - integrations?: Integrations - context?: AnalyticsContext | Options - options?: Options - - userId?: ID - anonymousId?: ID - groupId?: ID - previousId?: ID - - event?: string - - /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L284} - */ - writeKey?: string - - /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L151} - */ - sentAt?: Date - - /** - * {@link https://github.com/segmentio/analytics.js-integrations/blob/2d5c637c022d2661c23449aed237d0d546bf062d/integrations/segmentio/lib/index.js#L311-L320} - */ - _metadata?: { - failedInitializations?: unknown[] - bundled?: string[] - unbundled?: string[] - nodeVersion?: string - bundledConfigIds?: string[] - unbundledConfigIds?: string[] - bundledIds?: string[] - } - - timestamp?: Date | string -} - -/** - * A Plan allows users to specify events and which destinations they would like them to be sent to - */ -export interface Plan { - track?: TrackPlan - identify?: TrackPlan - group?: TrackPlan -} - -export interface TrackPlan { - [key: string]: PlanEvent | undefined - // __default SHOULD always exist, but marking as optional for extra safety. - __default?: PlanEvent -} - -export interface PlanEvent { - /** - * Whether or not this plan event is enabled - */ - enabled: boolean - /** - * Which integrations the plan event applies to - */ - integrations?: { - [key: string]: boolean - } -} - -export interface ReservedTraits { - address: Partial<{ - city: string - country: string - postalCode: string - state: string - street: string - }> - age: number - avatar: string - birthday: Date - company: Partial<{ - name: string - id: string | number - industry: string - employee_count: number - }> - plan: string - createdAt: Date - description: string - email: string - firstName: string - gender: string - id: string - lastName: string - name: string - phone: string - title: string - username: string - website: string +export interface SegmentEvent extends CoreSegmentEvent {} + +export type { + Integrations, + Plan, + TrackPlan, + PlanEvent, + Callback, + JSONArray, + JSONValue, + JSONPrimitive, + JSONObject, } diff --git a/packages/browser/src/core/inspector/index.ts b/packages/browser/src/core/inspector/index.ts index fc84ae7cc..9012388a5 100644 --- a/packages/browser/src/core/inspector/index.ts +++ b/packages/browser/src/core/inspector/index.ts @@ -20,12 +20,14 @@ const inspectorHost: Partial = ((env as any)[ export const attachInspector = (analytics: Analytics) => { inspectorHost.attach?.(analytics as any) - analytics.on('dispatch_start', (ctx) => inspectorHost.triggered?.(ctx)) + analytics.on('dispatch_start', (ctx) => inspectorHost.triggered?.(ctx as any)) - analytics.queue.on('message_enriched', (ctx) => inspectorHost.enriched?.(ctx)) + analytics.queue.on('message_enriched', (ctx) => + inspectorHost.enriched?.(ctx as any) + ) analytics.queue.on('message_delivered', (ctx) => // FIXME: Resolve browsers destinations that the event was sent to - inspectorHost.delivered?.(ctx, ['segment.io']) + inspectorHost.delivered?.(ctx as any, ['segment.io']) ) } diff --git a/packages/browser/src/core/logger/__tests__/index.test.ts b/packages/browser/src/core/logger/__tests__/index.test.ts deleted file mode 100644 index 4ce69d359..000000000 --- a/packages/browser/src/core/logger/__tests__/index.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import Logger from '..' - -describe(Logger, () => { - let logger: Logger - - beforeEach(() => { - logger = new Logger() - }) - - it('logs events at different levels', () => { - logger.log('debug', 'Debugging', { test: 'debug', emoji: '🐛' }) - logger.log('info', 'Info', { test: 'info', emoji: '📰' }) - logger.log('warn', 'Warning', { test: 'warn', emoji: '⚠️' }) - logger.log('error', 'Error', { test: 'error', emoji: '💥' }) - - expect(logger.logs).toEqual([ - { - extras: { - emoji: '🐛', - test: 'debug', - }, - level: 'debug', - message: 'Debugging', - time: expect.any(Date), - }, - { - extras: { - emoji: '📰', - test: 'info', - }, - level: 'info', - message: 'Info', - time: expect.any(Date), - }, - { - extras: { - emoji: '⚠️', - test: 'warn', - }, - level: 'warn', - message: 'Warning', - time: expect.any(Date), - }, - { - extras: { - emoji: '💥', - test: 'error', - }, - level: 'error', - message: 'Error', - time: expect.any(Date), - }, - ]) - }) - - it('flushes logs to the console', () => { - jest.spyOn(console, 'table').mockImplementationOnce(() => {}) - - logger.log('info', 'my log') - logger.log('debug', 'my log') - - logger.flush() - expect(console.table).toHaveBeenCalled() - expect(logger.logs).toEqual([]) - }) -}) diff --git a/packages/browser/src/core/logger/index.ts b/packages/browser/src/core/logger/index.ts deleted file mode 100644 index 86e37b5ca..000000000 --- a/packages/browser/src/core/logger/index.ts +++ /dev/null @@ -1,71 +0,0 @@ -export type LogLevel = 'debug' | 'info' | 'warn' | 'error' -export type LogMessage = { - level: LogLevel - message: string - time?: Date - extras?: object & { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any - } -} - -export default class Logger { - private _logs: LogMessage[] = [] - - log = (level: LogLevel, message: string, extras?: object): void => { - const time = new Date() - this._logs.push({ - level, - message, - time, - extras, - }) - } - - public get logs(): LogMessage[] { - return this._logs - } - - public flush(): void { - if (this.logs.length > 1) { - const formatted = this._logs.reduce((logs, log) => { - const line = { - ...log, - json: JSON.stringify(log.extras, null, ' '), - extras: log.extras, - } - - delete line['time'] - - let key = log.time?.toISOString() ?? '' - if (logs[key]) { - key = `${key}-${Math.random()}` - } - - return { - ...logs, - [key]: line, - } - }, {} as Record) - - // ie doesn't like console.table - if (console.table) { - console.table(formatted) - } else { - console.log(formatted) - } - } else { - this.logs.forEach((logEntry) => { - const { level, message, extras } = logEntry - - if (level === 'info' || level === 'debug') { - console.log(message, extras ?? '') - } else { - console[level](message, extras ?? '') - } - }) - } - - this._logs = [] - } -} diff --git a/packages/browser/src/core/plugin/index.ts b/packages/browser/src/core/plugin/index.ts index 5831bad1c..5cd35d601 100644 --- a/packages/browser/src/core/plugin/index.ts +++ b/packages/browser/src/core/plugin/index.ts @@ -1,36 +1,12 @@ -import { Analytics } from '../analytics' -import { Context } from '../context' +import type { CorePlugin } from '@segment/analytics-core' +import type { DestinationMiddlewareFunction } from '../../plugins/middleware' +import type { Analytics } from '../analytics' +import type { Context } from '../context' -interface PluginConfig { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - options?: any - priority?: 'critical' | 'non-critical' // whether AJS should expect this plugin to be loaded before starting event delivery -} - -// enrichment - modifies the event. Enrichment can happen in parallel, by reducing all changes in the final event. Failures in this stage could halt event delivery. -// destination - runs in parallel at the end of the lifecycle. Cannot modify the event, can fail and not halt execution. -// utility - do not affect lifecycle. Should be run and executed once. Their `track/identify` calls don't really do anything. example - -export interface Plugin { - name: string - version: string - type: 'before' | 'after' | 'destination' | 'enrichment' | 'utility' - alternativeNames?: string[] +export interface Plugin extends CorePlugin {} - isLoaded: () => boolean - load: ( - ctx: Context, - instance: Analytics, - config?: PluginConfig - ) => Promise - - unload?: (ctx: Context, instance: Analytics) => Promise | unknown - - ready?: () => Promise - track?: (ctx: Context) => Promise | Context - identify?: (ctx: Context) => Promise | Context - page?: (ctx: Context) => Promise | Context - group?: (ctx: Context) => Promise | Context - alias?: (ctx: Context) => Promise | Context - screen?: (ctx: Context) => Promise | Context +export interface DestinationPlugin extends Plugin { + addMiddleware: (...fns: DestinationMiddlewareFunction[]) => void } + +export type AnyBrowserPlugin = Plugin | DestinationPlugin diff --git a/packages/browser/src/core/query-string/__tests__/index.test.ts b/packages/browser/src/core/query-string/__tests__/index.test.ts index ca8ac905c..7301778c5 100644 --- a/packages/browser/src/core/query-string/__tests__/index.test.ts +++ b/packages/browser/src/core/query-string/__tests__/index.test.ts @@ -121,7 +121,7 @@ describe('queryString', () => { describe('setting anonymous id when making track and identify calls', () => { it('updates the anonymous ids before track calls are made', async () => { - const dispatchSpy = jest.spyOn(analytics as any, 'dispatch') + const dispatchSpy = jest.spyOn(analytics as any, '_dispatch') await queryString(analytics, '?ajs_event=event&ajs_aid=ariel') expect(dispatchSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -133,7 +133,7 @@ describe('queryString', () => { dispatchSpy.mockRestore() }) it('updates the anonymous ids before identify calls are made', async () => { - const dispatchSpy = jest.spyOn(analytics as any, 'dispatch') + const dispatchSpy = jest.spyOn(analytics as any, '_dispatch') await queryString(analytics, '?ajs_uid=1234&ajs_aid=ariel') expect(dispatchSpy).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/browser/src/core/queue/__tests__/event-queue.test.ts b/packages/browser/src/core/queue/__tests__/event-queue.test.ts index 8e024a190..06d5e0ef5 100644 --- a/packages/browser/src/core/queue/__tests__/event-queue.test.ts +++ b/packages/browser/src/core/queue/__tests__/event-queue.test.ts @@ -1,16 +1,8 @@ -/* eslint-disable @typescript-eslint/no-floating-promises */ -import { noop } from 'lodash' import { Analytics } from '../../analytics' import { pWhile } from '../../../lib/p-while' -import * as timer from '../../../lib/priority-queue/backoff' -import { - MiddlewareFunction, - sourceMiddlewarePlugin, -} from '../../../plugins/middleware' -import { Context, ContextCancelation } from '../../context' +import { Context } from '../../context' import { Plugin } from '../../plugin' import { EventQueue } from '../event-queue' -import { pTimeout } from '../../callback' import { ActionDestination } from '../../../plugins/remote-loader' async function flushAll(eq: EventQueue): Promise { @@ -40,750 +32,51 @@ const testPlugin: Plugin = { const ajs = {} as Analytics -let fruitBasket: Context, basketView: Context, shopper: Context - -beforeEach(() => { - fruitBasket = new Context({ - type: 'track', - event: 'Fruit Basket', - properties: { - banana: '🍌', - apple: '🍎', - grape: '🍇', - }, - }) - - basketView = new Context({ - type: 'page', - }) - - shopper = new Context({ - type: 'identify', - traits: { - name: 'Netto Farah', - }, - }) -}) - -test('can send events', async () => { - const eq = new EventQueue() - const evt = await eq.dispatch(fruitBasket) - expect(evt).toBe(fruitBasket) -}) - -test('delivers events out of band', async () => { - jest.useFakeTimers() - - const eq = new EventQueue() - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - eq.dispatch(fruitBasket) - - expect(jest.getTimerCount()).toBe(1) - expect(eq.queue.includes(fruitBasket)).toBe(true) - - // run timers and deliver events - jest.runAllTimers() - await eq.flush() - - expect(eq.queue.length).toBe(0) -}) - -test('does not enqueue multiple flushes at once', async () => { - jest.useFakeTimers() - - const eq = new EventQueue() - - const anothaOne = new Context({ - type: 'page', - }) - - eq.dispatch(fruitBasket) - eq.dispatch(anothaOne) - - expect(jest.getTimerCount()).toBe(1) - expect(eq.queue.length).toBe(2) - - // Ensure already enqueued tasks are executed - jest.runAllTimers() - - // reset the world to use the real timers - jest.useRealTimers() - await flushAll(eq) - - expect(eq.queue.length).toBe(0) -}) - -describe('Flushing', () => { - beforeEach(() => { - jest.useRealTimers() - }) - - test('works until the queue is empty', async () => { - const eq = new EventQueue() - - eq.dispatch(fruitBasket) - eq.dispatch(basketView) - eq.dispatch(shopper) - - expect(eq.queue.length).toBe(3) - - const flushed = await flushAll(eq) - - expect(eq.queue.length).toBe(0) - expect(flushed).toEqual([fruitBasket, basketView, shopper]) - }) - - test('re-queues failed events', async () => { - const eq = new EventQueue() - - await eq.register( - Context.system(), - { - ...testPlugin, - track: (ctx) => { - if (ctx === fruitBasket) { - throw new Error('aaay') - } - - return Promise.resolve(ctx) - }, - }, - ajs - ) - - eq.dispatch(fruitBasket) - eq.dispatch(basketView) - eq.dispatch(shopper) - - expect(eq.queue.length).toBe(3) - - const flushed = await flushAll(eq) - - // flushed good events - expect(flushed).toEqual([basketView, shopper]) - - // attempted to deliver multiple times - expect(eq.queue.getAttempts(fruitBasket)).toEqual(2) - }) - - test('waits for critical tasks to finish before performing event deliveries', async () => { - jest.useRealTimers() - - const eq = new EventQueue() - - let finishCriticalTask: () => void = noop - const startTask = () => - new Promise((res) => (finishCriticalTask = res)) - - // some preceding events that've been scheduled - const p1 = eq.dispatch(fruitBasket) - const p2 = eq.dispatch(basketView) - // a critical task has been kicked off - eq.criticalTasks.run(startTask) - // a succeeding event - const p3 = eq.dispatch(shopper) - - // even after a good amount of time, none of the events should be delivered - await expect(pTimeout(Promise.race([p1, p2, p3]), 1000)).rejects.toThrow() - - // give the green light - finishCriticalTask() - - // now that the task is complete, the delivery should resume - expect(await Promise.all([p1, p2, p3])).toMatchObject([ - fruitBasket, - basketView, - shopper, - ]) - }) - - test('delivers events on retry', async () => { - jest.useRealTimers() - - // make sure all backoffs return immediatelly - jest.spyOn(timer, 'backoff').mockImplementationOnce(() => 100) - - const eq = new EventQueue() - - await eq.register( - Context.system(), - { - ...testPlugin, - track: (ctx) => { - // only fail first attempt - if (ctx === fruitBasket && ctx.attempts === 1) { - throw new Error('aaay') - } - - return Promise.resolve(ctx) - }, - }, - ajs - ) - - eq.dispatch(fruitBasket) - eq.dispatch(basketView) - eq.dispatch(shopper) - - expect(eq.queue.length).toBe(3) - - let flushed = await flushAll(eq) - // delivered both basket and shopper - expect(flushed).toEqual([basketView, shopper]) - - // wait for the exponential backoff - await new Promise((res) => setTimeout(res, 100)) - - // second try - flushed = await flushAll(eq) - expect(eq.queue.length).toBe(0) - - expect(flushed).toEqual([fruitBasket]) - expect(flushed[0].attempts).toEqual(2) - }) - - test('does not retry non retriable cancelations', async () => { - const eq = new EventQueue() - - await eq.register( - Context.system(), - { - ...testPlugin, - track: async (ctx) => { - if (ctx === fruitBasket) { - throw new ContextCancelation({ retry: false, reason: 'Test!' }) - } - return ctx - }, - }, - ajs - ) - - const dispatches = [ - eq.dispatch(fruitBasket), - eq.dispatch(basketView), - eq.dispatch(shopper), - ] - - expect(eq.queue.length).toBe(3) - - const flushed = await Promise.all(dispatches) - - // delivered both basket and shopper - expect(flushed).toEqual([fruitBasket, basketView, shopper]) - - // nothing was retried - expect(basketView.attempts).toEqual(1) - expect(shopper.attempts).toEqual(1) - expect(fruitBasket.attempts).toEqual(1) - expect(eq.queue.length).toBe(0) - }) - - test('does not retry non retriable cancelations (dispatchSingle)', async () => { - const eq = new EventQueue() - - await eq.register( - Context.system(), - { - ...testPlugin, - track: async (ctx) => { - if (ctx === fruitBasket) { - throw new ContextCancelation({ retry: false, reason: 'Test!' }) - } - return ctx - }, - }, - ajs - ) - - const context = await eq.dispatchSingle(fruitBasket) - - expect(context.attempts).toEqual(1) - }) - - test('retries retriable cancelations', async () => { - // make sure all backoffs return immediatelly - jest.spyOn(timer, 'backoff').mockImplementationOnce(() => 100) - - const eq = new EventQueue() - - await eq.register( - Context.system(), - { - ...testPlugin, - track: (ctx) => { - // only fail first attempt - if (ctx === fruitBasket && ctx.attempts === 1) { - ctx.cancel(new ContextCancelation({ retry: true })) - } - - return Promise.resolve(ctx) - }, - }, - ajs - ) - - eq.dispatch(fruitBasket) - eq.dispatch(basketView) - eq.dispatch(shopper) - - expect(eq.queue.length).toBe(3) - - let flushed = await flushAll(eq) - // delivered both basket and shopper - expect(flushed).toEqual([basketView, shopper]) - - // wait for the exponential backoff - await new Promise((res) => setTimeout(res, 100)) - - // second try - flushed = await flushAll(eq) - expect(eq.queue.length).toBe(0) - - expect(flushed).toEqual([fruitBasket]) - expect(flushed[0].attempts).toEqual(2) - }) +/** + * This test file only contains event-queue tests that _are_ specific to this package. + * You should prefer to write tests in packages/core + */ +const segmentio = { + ...testPlugin, + name: 'Segment.io', + type: 'after' as const, + track: (ctx: Context): Promise | Context => { + return Promise.resolve(ctx) + }, +} - test('client: can block on delivery', async () => { - jest.useRealTimers() +describe('alternative names', () => { + test('delivers to action destinations using alternative names', async () => { const eq = new EventQueue() - - await eq.register( - Context.system(), - { - ...testPlugin, - track: (ctx) => { - // only fail first attempt - if (ctx === fruitBasket && ctx.attempts === 1) { - throw new Error('aaay') - } - - return Promise.resolve(ctx) - }, - }, - ajs - ) - - const fruitBasketDelivery = eq.dispatch(fruitBasket) - const basketViewDelivery = eq.dispatch(basketView) - const shopperDelivery = eq.dispatch(shopper) - - expect(eq.queue.length).toBe(3) - - const [fruitBasketCtx, basketViewCtx, shopperCtx] = await Promise.all([ - fruitBasketDelivery, - basketViewDelivery, - shopperDelivery, - ]) - - expect(eq.queue.length).toBe(0) - - expect(fruitBasketCtx.attempts).toBe(2) - expect(basketViewCtx.attempts).toBe(1) - expect(shopperCtx.attempts).toBe(1) - }) - - describe('denyList permutations', () => { - const amplitude = { - ...testPlugin, - name: 'Amplitude', - type: 'destination' as const, - track: (ctx: Context): Promise | Context => { - return Promise.resolve(ctx) - }, - } - - const mixPanel = { - ...testPlugin, - name: 'Mixpanel', - type: 'destination' as const, - track: (ctx: Context): Promise | Context => { - return Promise.resolve(ctx) + const fullstory = new ActionDestination('fullstory', testPlugin) // TODO: This should be re-written as higher level integration test. + fullstory.alternativeNames.push('fullstory trackEvent') + fullstory.type = 'destination' + + jest.spyOn(fullstory, 'track') + jest.spyOn(segmentio, 'track') + + const evt = { + type: 'track' as const, + integrations: { + All: false, + 'fullstory trackEvent': true, + 'Segment.io': {}, }, } - const segmentio = { - ...testPlugin, - name: 'Segment.io', - type: 'after' as const, - track: (ctx: Context): Promise | Context => { - return Promise.resolve(ctx) - }, - } - - test('does not delivery to destinations on denyList', async () => { - const eq = new EventQueue() - - jest.spyOn(amplitude, 'track') - jest.spyOn(mixPanel, 'track') - jest.spyOn(segmentio, 'track') - - const evt = { - type: 'track' as const, - integrations: { - Mixpanel: false, - 'Segment.io': false, - }, - } - - const ctx = new Context(evt) - - await eq.register(Context.system(), amplitude, ajs) - await eq.register(Context.system(), mixPanel, ajs) - await eq.register(Context.system(), segmentio, ajs) - - eq.dispatch(ctx) - - expect(eq.queue.length).toBe(1) - - const flushed = await flushAll(eq) - - expect(flushed).toEqual([ctx]) - - expect(mixPanel.track).not.toHaveBeenCalled() - expect(amplitude.track).toHaveBeenCalled() - expect(segmentio.track).not.toHaveBeenCalled() - }) - - test('does not deliver to any destination except Segment.io if All: false ', async () => { - const eq = new EventQueue() - - jest.spyOn(amplitude, 'track') - jest.spyOn(mixPanel, 'track') - jest.spyOn(segmentio, 'track') - - const evt = { - type: 'track' as const, - integrations: { - All: false, - }, - } - - const ctx = new Context(evt) - - await eq.register(Context.system(), amplitude, ajs) - await eq.register(Context.system(), mixPanel, ajs) - await eq.register(Context.system(), segmentio, ajs) - - eq.dispatch(ctx) - - expect(eq.queue.length).toBe(1) - const flushed = await flushAll(eq) - - expect(flushed).toEqual([ctx]) - - expect(mixPanel.track).not.toHaveBeenCalled() - expect(amplitude.track).not.toHaveBeenCalled() - expect(segmentio.track).toHaveBeenCalled() - }) - - test('does not deliver when All: false and destination is also explicitly false', async () => { - const eq = new EventQueue() - - jest.spyOn(amplitude, 'track') - jest.spyOn(mixPanel, 'track') - jest.spyOn(segmentio, 'track') - - const evt = { - type: 'track' as const, - integrations: { - All: false, - Amplitude: false, - 'Segment.io': false, - }, - } - - const ctx = new Context(evt) - - await eq.register(Context.system(), amplitude, ajs) - await eq.register(Context.system(), mixPanel, ajs) - await eq.register(Context.system(), segmentio, ajs) - - eq.dispatch(ctx) - - expect(eq.queue.length).toBe(1) - const flushed = await flushAll(eq) - - expect(flushed).toEqual([ctx]) - - expect(mixPanel.track).not.toHaveBeenCalled() - expect(amplitude.track).not.toHaveBeenCalled() - expect(segmentio.track).not.toHaveBeenCalled() - }) - - test('delivers to destinations if All: false but the destination is allowed', async () => { - const eq = new EventQueue() - - jest.spyOn(amplitude, 'track') - jest.spyOn(mixPanel, 'track') - jest.spyOn(segmentio, 'track') - - const evt = { - type: 'track' as const, - integrations: { - All: false, - Amplitude: true, - 'Segment.io': true, - }, - } - - const ctx = new Context(evt) - - await eq.register(Context.system(), amplitude, ajs) - await eq.register(Context.system(), mixPanel, ajs) - await eq.register(Context.system(), segmentio, ajs) - - eq.dispatch(ctx) - - expect(eq.queue.length).toBe(1) - const flushed = await flushAll(eq) - - expect(flushed).toEqual([ctx]) - - expect(mixPanel.track).not.toHaveBeenCalled() - expect(amplitude.track).toHaveBeenCalled() - expect(segmentio.track).toHaveBeenCalled() - }) - - test('delivers to Segment.io if All: false but Segment.io is not specified', async () => { - const eq = new EventQueue() - - jest.spyOn(amplitude, 'track') - jest.spyOn(mixPanel, 'track') - jest.spyOn(segmentio, 'track') - - const evt = { - type: 'track' as const, - integrations: { - All: false, - Amplitude: true, - }, - } - - const ctx = new Context(evt) - - await eq.register(Context.system(), amplitude, ajs) - await eq.register(Context.system(), mixPanel, ajs) - await eq.register(Context.system(), segmentio, ajs) - - eq.dispatch(ctx) - - expect(eq.queue.length).toBe(1) - const flushed = await flushAll(eq) - - expect(flushed).toEqual([ctx]) + const ctx = new Context(evt) - expect(mixPanel.track).not.toHaveBeenCalled() - expect(amplitude.track).toHaveBeenCalled() - expect(segmentio.track).toHaveBeenCalled() - }) + await eq.register(Context.system(), fullstory, ajs) + await eq.register(Context.system(), segmentio, ajs) - test('delivers to destinations that exist as an object', async () => { - const eq = new EventQueue() + void eq.dispatch(ctx) - jest.spyOn(amplitude, 'track') - jest.spyOn(segmentio, 'track') - - const evt = { - type: 'track' as const, - integrations: { - All: false, - Amplitude: { - amplitudeKey: 'foo', - }, - 'Segment.io': {}, - }, - } - - const ctx = new Context(evt) - - await eq.register(Context.system(), amplitude, ajs) - await eq.register(Context.system(), segmentio, ajs) - - eq.dispatch(ctx) - - expect(eq.queue.length).toBe(1) - const flushed = await flushAll(eq) - - expect(flushed).toEqual([ctx]) - - expect(amplitude.track).toHaveBeenCalled() - expect(segmentio.track).toHaveBeenCalled() - }) - - test('delivers to action destinations using alternative names', async () => { - const eq = new EventQueue() - const fullstory = new ActionDestination('fullstory', testPlugin) - fullstory.alternativeNames.push('fullstory trackEvent') - fullstory.type = 'destination' - - jest.spyOn(fullstory, 'track') - jest.spyOn(segmentio, 'track') - - const evt = { - type: 'track' as const, - integrations: { - All: false, - 'fullstory trackEvent': true, - 'Segment.io': {}, - }, - } - - const ctx = new Context(evt) - - await eq.register(Context.system(), fullstory, ajs) - await eq.register(Context.system(), segmentio, ajs) - - eq.dispatch(ctx) - - expect(eq.queue.length).toBe(1) - const flushed = await flushAll(eq) - - expect(flushed).toEqual([ctx]) - - expect(fullstory.track).toHaveBeenCalled() - expect(segmentio.track).toHaveBeenCalled() - }) - - test('respect deny lists generated by other plugin', async () => { - const eq = new EventQueue() - - jest.spyOn(amplitude, 'track') - jest.spyOn(mixPanel, 'track') - jest.spyOn(segmentio, 'track') - - const evt = { - type: 'track' as const, - integrations: { - Amplitude: true, - MixPanel: true, - 'Segment.io': true, - }, - } - - const ctx = new Context(evt) - await eq.register(Context.system(), amplitude, ajs) - await eq.register(Context.system(), mixPanel, ajs) - await eq.register(Context.system(), segmentio, ajs) - await eq.dispatch(ctx) - - const skipAmplitudeAndSegment: MiddlewareFunction = ({ - payload, - next, - }) => { - if (!payload.obj.integrations) { - payload.obj.integrations = {} - } - - payload.obj.integrations['Amplitude'] = false - payload.obj.integrations['Segment.io'] = false - next(payload) - } - - await eq.register( - Context.system(), - sourceMiddlewarePlugin(skipAmplitudeAndSegment, {}), - ajs - ) - - await eq.dispatch(ctx) - - expect(mixPanel.track).toHaveBeenCalledTimes(2) - expect(amplitude.track).toHaveBeenCalledTimes(1) - expect(segmentio.track).toHaveBeenCalledTimes(1) - }) - }) -}) - -describe('deregister', () => { - it('remove plugin from plugins list', async () => { - const eq = new EventQueue() - const toBeRemoved = { ...testPlugin, name: 'remove-me' } - const plugins = [testPlugin, toBeRemoved] - - const promises = plugins.map((p) => eq.register(Context.system(), p, ajs)) - await Promise.all(promises) - - await eq.deregister(Context.system(), toBeRemoved, ajs) - expect(eq.plugins.length).toBe(1) - expect(eq.plugins[0]).toBe(testPlugin) - }) - - it('invokes plugin.unload', async () => { - const eq = new EventQueue() - const toBeRemoved = { ...testPlugin, name: 'remove-me', unload: jest.fn() } - const plugins = [testPlugin, toBeRemoved] - - const promises = plugins.map((p) => eq.register(Context.system(), p, ajs)) - await Promise.all(promises) - - await eq.deregister(Context.system(), toBeRemoved, ajs) - expect(toBeRemoved.unload).toHaveBeenCalled() - expect(eq.plugins.length).toBe(1) - expect(eq.plugins[0]).toBe(testPlugin) - }) -}) - -describe('dispatchSingle', () => { - it('dispatches events without placing them on the queue', async () => { - const eq = new EventQueue() - const promise = eq.dispatchSingle(fruitBasket) - - expect(eq.queue.length).toBe(0) - await promise - expect(eq.queue.length).toBe(0) - }) - - it('records delivery metrics', async () => { - const eq = new EventQueue() - const ctx = await eq.dispatchSingle( - new Context({ - type: 'track', - }) - ) - - expect(ctx.logs().map((l) => l.message)).toMatchInlineSnapshot(` - Array [ - "Dispatching", - "Delivered", - ] - `) - - expect(ctx.stats.metrics.map((m) => m.metric)).toMatchInlineSnapshot(` - Array [ - "message_dispatched", - "message_delivered", - "delivered", - ] - `) - }) - - test('retries retriable cancelations', async () => { - // make sure all backoffs return immediatelly - jest.spyOn(timer, 'backoff').mockImplementationOnce(() => 100) - - const eq = new EventQueue() - - await eq.register( - Context.system(), - { - ...testPlugin, - track: (ctx) => { - // only fail first attempt - if (ctx === fruitBasket && ctx.attempts === 1) { - ctx.cancel(new ContextCancelation({ retry: true })) - } - - return Promise.resolve(ctx) - }, - }, - ajs - ) + expect(eq.queue.length).toBe(1) + const flushed = await flushAll(eq) - expect(eq.queue.length).toBe(0) + expect(flushed).toEqual([ctx]) - const attempted = await eq.dispatchSingle(fruitBasket) - expect(attempted.attempts).toEqual(2) + expect(fullstory.track).toHaveBeenCalled() + expect(segmentio.track).toHaveBeenCalled() }) }) diff --git a/packages/browser/src/core/queue/__tests__/extension-flushing.test.ts b/packages/browser/src/core/queue/__tests__/extension-flushing.test.ts deleted file mode 100644 index bae1ddf73..000000000 --- a/packages/browser/src/core/queue/__tests__/extension-flushing.test.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { shuffle } from 'lodash' -import { Analytics } from '../../analytics' -import { PriorityQueue } from '../../../lib/priority-queue' -import { Context } from '../../context' -import { Plugin } from '../../plugin' -import { EventQueue } from '../event-queue' - -const fruitBasket = new Context({ - type: 'track', - event: 'Fruit Basket', - properties: { - banana: '🍌', - apple: '🍎', - grape: '🍇', - }, -}) - -const testPlugin: Plugin = { - name: 'test', - type: 'before', - version: '0.1.0', - load: () => Promise.resolve(), - isLoaded: () => true, -} - -const ajs = {} as Analytics - -describe('Registration', () => { - test('can register plugins', async () => { - const eq = new EventQueue() - const load = jest.fn() - - const plugin: Plugin = { - name: 'test', - type: 'before', - version: '0.1.0', - load, - isLoaded: () => true, - } - - const ctx = Context.system() - await eq.register(ctx, plugin, ajs) - - expect(load).toHaveBeenCalledWith(ctx, ajs) - }) - - test('fails if plugin cant be loaded', async () => { - const eq = new EventQueue() - - const plugin: Plugin = { - name: 'test', - type: 'before', - version: '0.1.0', - load: () => Promise.reject(new Error('👻')), - isLoaded: () => false, - } - - const ctx = Context.system() - await expect( - eq.register(ctx, plugin, ajs) - ).rejects.toThrowErrorMatchingInlineSnapshot(`"👻"`) - }) - - test('allows for destinations to fail registration', async () => { - const eq = new EventQueue() - - const plugin: Plugin = { - name: 'test', - type: 'destination', - version: '0.1.0', - load: () => Promise.reject(new Error('👻')), - isLoaded: () => false, - } - - const ctx = Context.system() - await eq.register(ctx, plugin, ajs) - - expect(ctx.logs()[0].level).toEqual('warn') - expect(ctx.logs()[0].message).toEqual('Failed to load destination') - }) -}) - -describe('Plugin flushing', () => { - test('ensures `before` plugins are run', async () => { - const eq = new EventQueue() - const queue = new PriorityQueue(1, []) - - eq.queue = queue - - await eq.register( - Context.system(), - { - ...testPlugin, - type: 'before', - }, - ajs - ) - - const flushed = await eq.dispatch(fruitBasket) - expect(flushed.logs().map((l) => l.message)).toContain('Delivered') - - await eq.register( - Context.system(), - { - ...testPlugin, - name: 'Faulty before', - type: 'before', - track: () => { - throw new Error('aaay') - }, - }, - ajs - ) - - const failedFlush: Context = await eq - .dispatch( - new Context({ - type: 'track', - }) - ) - .catch((ctx) => ctx) - - const messages = failedFlush.logs().map((l) => l.message) - expect(messages).not.toContain('Delivered') - }) - - test('atempts `enrichment` plugins', async () => { - const eq = new EventQueue() - - await eq.register( - Context.system(), - { - ...testPlugin, - name: 'Faulty enrichment', - type: 'enrichment', - track: () => { - throw new Error('aaay') - }, - }, - ajs - ) - - const flushed = await eq.dispatch( - new Context({ - type: 'track', - }) - ) - - const messages = flushed.logs().map((l) => l.message) - expect(messages).toContain('Delivered') - }) - - test('attempts `destination` plugins', async () => { - const eq = new EventQueue() - - const amplitude: Plugin = { - ...testPlugin, - name: 'Amplitude', - type: 'destination', - track: async () => { - throw new Error('Boom!') - }, - } - - const fullstory: Plugin = { - ...testPlugin, - name: 'FullStory', - type: 'destination', - } - - await eq.register(Context.system(), amplitude, ajs) - await eq.register(Context.system(), fullstory, ajs) - - const flushed = await eq.dispatch( - new Context({ - type: 'track', - }) - ) - - const messages = flushed - .logs() - .map((l) => ({ message: l.message, extras: l.extras })) - - expect(messages).toMatchInlineSnapshot(` - Array [ - Object { - "extras": undefined, - "message": "Dispatching", - }, - Object { - "extras": Object { - "plugin": "Amplitude", - }, - "message": "plugin", - }, - Object { - "extras": Object { - "plugin": "FullStory", - }, - "message": "plugin", - }, - Object { - "extras": Object { - "error": [Error: Boom!], - "plugin": "Amplitude", - }, - "message": "plugin Error", - }, - Object { - "extras": Object { - "type": "track", - }, - "message": "Delivered", - }, - ] - `) - }) - - test('attempts `after` plugins', async () => { - const eq = new EventQueue() - - const afterFailed: Plugin = { - ...testPlugin, - name: 'after-failed', - type: 'after', - track: async () => { - throw new Error('Boom!') - }, - } - - const after: Plugin = { - ...testPlugin, - name: 'after', - type: 'after', - } - - await eq.register(Context.system(), afterFailed, ajs) - await eq.register(Context.system(), after, ajs) - - const flushed = await eq.dispatch( - new Context({ - type: 'track', - }) - ) - - const messages = flushed - .logs() - .map((l) => ({ message: l.message, extras: l.extras })) - expect(messages).toMatchInlineSnapshot(` - Array [ - Object { - "extras": undefined, - "message": "Dispatching", - }, - Object { - "extras": Object { - "plugin": "after-failed", - }, - "message": "plugin", - }, - Object { - "extras": Object { - "plugin": "after", - }, - "message": "plugin", - }, - Object { - "extras": Object { - "error": [Error: Boom!], - "plugin": "after-failed", - }, - "message": "plugin Error", - }, - Object { - "extras": Object { - "type": "track", - }, - "message": "Delivered", - }, - ] - `) - }) - - test('runs `enrichment` and `before` inline', async () => { - const eq = new EventQueue() - - await eq.register( - Context.system(), - { - ...testPlugin, - name: 'Kiwi', - type: 'enrichment', - track: async (ctx) => { - ctx.updateEvent('properties.kiwi', '🥝') - return ctx - }, - }, - ajs - ) - - await eq.register( - Context.system(), - { - ...testPlugin, - name: 'Watermelon', - type: 'enrichment', - track: async (ctx) => { - ctx.updateEvent('properties.watermelon', '🍉') - return ctx - }, - }, - ajs - ) - - await eq.register( - Context.system(), - { - ...testPlugin, - name: 'Before', - type: 'before', - track: async (ctx) => { - ctx.stats.increment('before') - return ctx - }, - }, - ajs - ) - - const flushed = await eq.dispatch( - new Context({ - type: 'track', - }) - ) - - expect(flushed.event.properties).toEqual({ - watermelon: '🍉', - kiwi: '🥝', - }) - - expect(flushed.stats.metrics.map((m) => m.metric)).toContain('before') - }) - - test('respects execution order', async () => { - const eq = new EventQueue() - - let trace = 0 - - const before: Plugin = { - ...testPlugin, - name: 'Before', - type: 'before', - track: async (ctx) => { - trace++ - expect(trace).toBe(1) - return ctx - }, - } - - const enrichment: Plugin = { - ...testPlugin, - name: 'Enrichment', - type: 'enrichment', - track: async (ctx) => { - trace++ - expect(trace === 2 || trace === 3).toBe(true) - return ctx - }, - } - - const enrichmentTwo: Plugin = { - ...testPlugin, - name: 'Enrichment 2', - type: 'enrichment', - track: async (ctx) => { - trace++ - expect(trace === 2 || trace === 3).toBe(true) - return ctx - }, - } - - const destination: Plugin = { - ...testPlugin, - name: 'Destination', - type: 'destination', - track: async (ctx) => { - trace++ - expect(trace).toBe(4) - return ctx - }, - } - - // shuffle plugins so we can verify order - const plugins = shuffle([before, enrichment, enrichmentTwo, destination]) - for (const xt of plugins) { - await eq.register(Context.system(), xt, ajs) - } - - await eq.dispatch( - new Context({ - type: 'track', - }) - ) - - expect(trace).toBe(4) - }) -}) diff --git a/packages/browser/src/core/queue/delivery.ts b/packages/browser/src/core/queue/delivery.ts deleted file mode 100644 index 140bd79b2..000000000 --- a/packages/browser/src/core/queue/delivery.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { ActionDestination } from '../../plugins/remote-loader' -import { Context, ContextCancelation } from '../context' -import { Plugin } from '../plugin' - -async function tryOperation( - op: () => Context | Promise -): Promise { - try { - return await op() - } catch (err) { - return Promise.reject(err) - } -} - -export function attempt( - ctx: Context, - plugin: Plugin | ActionDestination -): Promise { - const name = 'action' in plugin ? plugin.action.name : plugin.name - - ctx.log('debug', 'plugin', { plugin: name }) - - const start = new Date().getTime() - - const hook = plugin[ctx.event.type] - if (hook === undefined) { - return Promise.resolve(ctx) - } - - const newCtx = tryOperation(() => hook.apply(plugin, [ctx])) - .then((ctx) => { - const done = new Date().getTime() - start - ctx.stats.gauge('plugin_time', done, [`plugin:${name}`]) - return ctx - }) - .catch((err) => { - if ( - err instanceof ContextCancelation && - err.type === 'middleware_cancellation' - ) { - throw err - } - - if (err instanceof ContextCancelation) { - ctx.log('warn', err.type, { - plugin: name, - error: err, - }) - - return err - } - - ctx.log('error', 'plugin Error', { - plugin: name, - error: err, - }) - - ctx.stats.increment('plugin_error', 1, [`plugin:${name}`]) - return err as Error - }) - - return newCtx -} - -export function ensure( - ctx: Context, - plugin: Plugin -): Promise { - return attempt(ctx, plugin).then((newContext) => { - if (newContext instanceof Context) { - return newContext - } - - ctx.log('debug', 'Context canceled') - ctx.stats.increment('context_canceled') - ctx.cancel(newContext) - }) -} diff --git a/packages/browser/src/core/queue/event-queue.ts b/packages/browser/src/core/queue/event-queue.ts index 58f1b441f..f9f682ccd 100644 --- a/packages/browser/src/core/queue/event-queue.ts +++ b/packages/browser/src/core/queue/event-queue.ts @@ -1,313 +1,11 @@ -import { Analytics } from '../analytics' -import { groupBy } from '../../lib/group-by' -import { ON_REMOVE_FROM_FUTURE, PriorityQueue } from '../../lib/priority-queue' +import { PriorityQueue } from '../../lib/priority-queue' import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted' -import { isOnline } from '../connection' -import { Context, ContextCancelation } from '../context' -import { Emitter } from '@segment/analytics-core' -import { Integrations, JSONObject } from '../events' -import { Plugin } from '../plugin' -import { createTaskGroup, TaskGroup } from '../task/task-group' -import { attempt, ensure } from './delivery' - -type PluginsByType = { - before: Plugin[] - after: Plugin[] - enrichment: Plugin[] - destinations: Plugin[] -} - -export class EventQueue extends Emitter { - /** - * All event deliveries get suspended until all the tasks in this task group are complete. - * For example: a middleware that augments the event object should be loaded safely as a - * critical task, this way, event queue will wait for it to be ready before sending events. - * - * This applies to all the events already in the queue, and the upcoming ones - */ - criticalTasks: TaskGroup = createTaskGroup() - queue: PriorityQueue - plugins: Plugin[] = [] - failedInitializations: string[] = [] - private flushing = false +import { Context } from '../context' +import { AnyBrowserPlugin } from '../plugin' +import { CoreEventQueue } from '@segment/analytics-core' +export class EventQueue extends CoreEventQueue { constructor(priorityQueue?: PriorityQueue) { - super() - this.queue = priorityQueue ?? new PersistedPriorityQueue(4, 'event-queue') - this.queue.on(ON_REMOVE_FROM_FUTURE, () => { - this.scheduleFlush(0) - }) - } - - async register( - ctx: Context, - plugin: Plugin, - instance: Analytics - ): Promise { - await Promise.resolve(plugin.load(ctx, instance)) - .then(() => { - this.plugins.push(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 - } - - throw err - }) - } - - async deregister( - ctx: Context, - plugin: Plugin, - instance: Analytics - ): Promise { - try { - if (plugin.unload) { - await Promise.resolve(plugin.unload(ctx, instance)) - } - - this.plugins = this.plugins.filter((p) => p.name !== plugin.name) - } catch (e) { - ctx.log('warn', 'Failed to unload destination', { - plugin: plugin.name, - error: e, - }) - } - } - - async dispatch(ctx: Context): Promise { - ctx.log('debug', 'Dispatching') - ctx.stats.increment('message_dispatched') - - this.queue.push(ctx) - const willDeliver = this.subscribeToDelivery(ctx) - this.scheduleFlush(0) - return willDeliver - } - - private async subscribeToDelivery(ctx: Context): Promise { - return new Promise((resolve) => { - const onDeliver = (flushed: Context, delivered: boolean): void => { - if (flushed.isSame(ctx)) { - this.off('flush', onDeliver) - if (delivered) { - resolve(flushed) - } else { - resolve(flushed) - } - } - } - - this.on('flush', onDeliver) - }) - } - - async dispatchSingle(ctx: Context): Promise { - ctx.log('debug', 'Dispatching') - ctx.stats.increment('message_dispatched') - - this.queue.updateAttempts(ctx) - ctx.attempts = 1 - - return this.deliver(ctx).catch((err) => { - if (err instanceof ContextCancelation && err.retry === false) { - ctx.setFailedDelivery({ reason: err }) - return ctx - } - - const accepted = this.enqueuRetry(err, ctx) - if (!accepted) { - ctx.setFailedDelivery({ reason: err }) - return ctx - } - - return this.subscribeToDelivery(ctx) - }) - } - - isEmpty(): boolean { - return this.queue.length === 0 - } - - private scheduleFlush(timeout = 500): void { - if (this.flushing) { - return - } - - this.flushing = true - - setTimeout(() => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.flush().then(() => { - setTimeout(() => { - this.flushing = false - - if (this.queue.length) { - this.scheduleFlush(0) - } - }, 0) - }) - }, timeout) - } - - private async deliver(ctx: Context): Promise { - await this.criticalTasks.done() - - const start = Date.now() - try { - ctx = await this.flushOne(ctx) - const done = Date.now() - start - ctx.stats.gauge('delivered', done) - ctx.log('debug', 'Delivered', ctx.event) - return ctx - } catch (err) { - ctx.log('error', 'Failed to deliver', err as object) - ctx.stats.increment('delivery_failed') - throw err - } - } - - private enqueuRetry(err: Error, ctx: Context): boolean { - const notRetriable = - err instanceof ContextCancelation && err.retry === false - const retriable = !notRetriable - - if (retriable) { - const accepted = this.queue.pushWithBackoff(ctx) - return accepted - } - - return false - } - - async flush(): Promise { - if (this.queue.length === 0 || !isOnline()) { - return [] - } - - let ctx = this.queue.pop() - if (!ctx) { - return [] - } - - ctx.attempts = this.queue.getAttempts(ctx) - - try { - ctx = await this.deliver(ctx) - this.emit('flush', ctx, true) - } catch (err: any) { - const accepted = this.enqueuRetry(err, ctx) - - if (!accepted) { - ctx.setFailedDelivery({ reason: err }) - this.emit('flush', ctx, false) - } - - return [] - } - - return [ctx] - } - - private isReady(): boolean { - // return this.plugins.every((p) => p.isLoaded()) - // should we wait for every plugin to load? - return true - } - - private availableExtensions(denyList: Integrations): PluginsByType { - const available = this.plugins.filter((p) => { - // Only filter out destination plugins or the Segment.io plugin - if (p.type !== 'destination' && p.name !== 'Segment.io') { - return true - } - - let alternativeNameMatch: boolean | JSONObject | undefined = undefined - p.alternativeNames?.forEach((name) => { - if (denyList[name] !== undefined) { - alternativeNameMatch = denyList[name] - } - }) - - // Explicit integration option takes precedence, `All: false` does not apply to Segment.io - return ( - denyList[p.name] ?? - alternativeNameMatch ?? - (p.name === 'Segment.io' ? true : denyList.All) !== false - ) - }) - - const { - before = [], - enrichment = [], - destination = [], - after = [], - } = groupBy(available, 'type') - - return { - before, - enrichment, - destinations: destination, - after, - } - } - - private async flushOne(ctx: Context): Promise { - if (!this.isReady()) { - throw new Error('Not ready') - } - - const { before, enrichment } = this.availableExtensions( - ctx.event.integrations ?? {} - ) - - for (const beforeWare of before) { - const temp: Context | undefined = await ensure(ctx, beforeWare) - if (temp instanceof Context) { - ctx = temp - } - } - - for (const enrichmentWare of enrichment) { - const temp = await attempt(ctx, enrichmentWare) - if (temp instanceof Context) { - ctx = temp - } - } - - this.emit('message_enriched', ctx) - - // Enrichment and before plugins can re-arrange the deny list dynamically - // so we need to pluck them at the end - const { destinations, after } = this.availableExtensions( - ctx.event.integrations ?? {} - ) - - await new Promise((resolve, reject) => { - setTimeout(() => { - const attempts = destinations.map((destination) => - attempt(ctx, destination) - ) - Promise.all(attempts).then(resolve).catch(reject) - }, 0) - }) - - ctx.stats.increment('message_delivered') - - this.emit('message_delivered', ctx) - - const afterCalls = after.map((after) => attempt(ctx, after)) - await Promise.all(afterCalls) - - return ctx + super(priorityQueue ?? new PersistedPriorityQueue(4, 'event-queue')) } } diff --git a/packages/browser/src/core/stats/__tests__/index.test.ts b/packages/browser/src/core/stats/__tests__/index.test.ts index 38c8ba406..e5fc9ea1c 100644 --- a/packages/browser/src/core/stats/__tests__/index.test.ts +++ b/packages/browser/src/core/stats/__tests__/index.test.ts @@ -1,105 +1,7 @@ -import Stats from '..' +import { Stats } from '..' import { RemoteMetrics } from '../remote-metrics' describe(Stats, () => { - test('starts out empty', () => { - const stats = new Stats() - expect(stats.metrics).toEqual([]) - }) - - test('records increments', () => { - const stats = new Stats() - - stats.increment('m1') - stats.increment('m2', 2) - stats.increment('m3', 3, ['test:env']) - - expect(stats.metrics).toEqual([ - { - metric: 'm1', - tags: [], - timestamp: expect.any(Number), - type: 'counter', - value: 1, - }, - { - metric: 'm2', - tags: [], - timestamp: expect.any(Number), - type: 'counter', - value: 2, - }, - { - metric: 'm3', - tags: ['test:env'], - timestamp: expect.any(Number), - type: 'counter', - value: 3, - }, - ]) - }) - - test('records gauges', () => { - const stats = new Stats() - - stats.gauge('m1', 1) - stats.gauge('m2', 2, ['test:env']) - - expect(stats.metrics).toEqual([ - { - metric: 'm1', - tags: [], - timestamp: expect.any(Number), - type: 'gauge', - value: 1, - }, - { - metric: 'm2', - tags: ['test:env'], - timestamp: expect.any(Number), - type: 'gauge', - value: 2, - }, - ]) - }) - - test('serializes metrics to a more compact format', () => { - const stats = new Stats() - - stats.gauge('some_gauge', 31, ['test:env']) - stats.increment('some_increment', 22, ['test:env']) - - expect(stats.serialize()).toEqual([ - { - e: expect.any(Number), - k: 'g', - m: 'some_gauge', - t: ['test:env'], - v: 31, - }, - { - e: expect.any(Number), - k: 'c', - m: 'some_increment', - t: ['test:env'], - v: 22, - }, - ]) - }) - - test('flushes metrics', () => { - jest.spyOn(console, 'table').mockImplementationOnce(() => {}) - - const stats = new Stats() - stats.gauge('some_gauge', 31, ['test:env']) - stats.increment('some_increment', 22, ['test:env']) - - stats.flush() - - expect(stats.metrics).toEqual([]) - expect(console.table).toHaveBeenCalled() - }) - test('forwards increments to remote metrics endpoint', () => { const remote = new RemoteMetrics() const spy = jest.spyOn(remote, 'increment') diff --git a/packages/browser/src/core/stats/__tests__/remote-metrics.test.ts b/packages/browser/src/core/stats/__tests__/remote-metrics.test.ts index f724aeb8d..9966becf2 100644 --- a/packages/browser/src/core/stats/__tests__/remote-metrics.test.ts +++ b/packages/browser/src/core/stats/__tests__/remote-metrics.test.ts @@ -13,20 +13,17 @@ describe('remote metrics', () => { }) remote.increment('analytics_js.banana', ['phone:1']) - expect(remote.queue).toMatchInlineSnapshot(` - Array [ - Object { - "metric": "analytics_js.banana", - "tags": Object { - "library": "analytics.js", - "library_version": "npm:next-${version}", - "phone": "1", - }, - "type": "Counter", - "value": 1, - }, - ] - `) + expect(remote.queue.length).toBe(1) + const metric = remote.queue[0] + expect(metric.tags).toEqual({ + library: 'analytics.js', + library_version: `npm:next-${version}`, + phone: '1', + }) + + expect(metric.metric).toBe('analytics_js.banana') + expect(metric.type).toBe('counter') + expect(metric.value).toBe(1) }) test('does not store when not sampling', () => { @@ -77,18 +74,49 @@ describe('remote metrics', () => { await remote.flush() expect(spy).toHaveBeenCalled() - expect(spy.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "https://api.segment.io/v1/m", - Object { - "body": "{\\"series\\":[{\\"type\\":\\"Counter\\",\\"metric\\":\\"analytics_js.banana\\",\\"value\\":1,\\"tags\\":{\\"phone\\":\\"1\\",\\"library\\":\\"analytics.js\\",\\"library_version\\":\\"npm:next-${version}\\"}}]}", - "headers": Object { - "Content-Type": "text/plain", - }, - "method": "POST", + const [url, request] = spy.mock.calls[0] + + expect(url).toBe('https://api.segment.io/v1/m') + expect(request).toMatchInlineSnapshot( + { body: expect.anything() }, + ` + Object { + "body": Anything, + "headers": Object { + "Content-Type": "text/plain", }, - ] - `) + "method": "POST", + } + ` + ) + const body = JSON.parse(request?.body as any) + expect(body).toMatchInlineSnapshot( + { + series: [ + { + tags: { + library_version: expect.any(String), + }, + }, + ], + }, + ` + Object { + "series": Array [ + Object { + "metric": "analytics_js.banana", + "tags": Object { + "library": "analytics.js", + "library_version": Any, + "phone": "1", + }, + "type": "counter", + "value": 1, + }, + ], + } + ` + ) }) test('clears queue after sending', async () => { diff --git a/packages/browser/src/core/stats/index.ts b/packages/browser/src/core/stats/index.ts index 461c809b1..0e088808b 100644 --- a/packages/browser/src/core/stats/index.ts +++ b/packages/browser/src/core/stats/index.ts @@ -1,89 +1,12 @@ -import { RemoteMetrics } from './remote-metrics' +import { CoreStats } from '@segment/analytics-core' +import type { RemoteMetrics } from './remote-metrics' -type MetricType = 'gauge' | 'counter' -type CompactMetricType = 'g' | 'c' - -export interface Metric { - metric: string - value: number - type: MetricType - tags: string[] - timestamp: number // unit milliseconds -} - -export interface CompactMetric { - m: string // metric name - v: number // value - k: CompactMetricType - t: string[] // tags - e: number // timestamp in unit milliseconds -} - -const compactMetricType = (type: MetricType): CompactMetricType => { - const enums: Record = { - gauge: 'g', - counter: 'c', - } - return enums[type] -} - -export default class Stats { - metrics: Metric[] = [] - - private remoteMetrics?: RemoteMetrics - - constructor(remoteMetrics?: RemoteMetrics) { - this.remoteMetrics = remoteMetrics +export class Stats extends CoreStats { + constructor(private _remoteMetrics?: RemoteMetrics) { + super() } - - increment(metric: string, by = 1, tags?: string[]): void { - this.metrics.push({ - metric, - value: by, - tags: tags ?? [], - type: 'counter', - timestamp: Date.now(), - }) - - this.remoteMetrics?.increment(metric, tags ?? []) - } - - gauge(metric: string, value: number, tags?: string[]): void { - this.metrics.push({ - metric, - value, - tags: tags ?? [], - type: 'gauge', - timestamp: Date.now(), - }) - } - - flush(): void { - const formatted = this.metrics.map((m) => ({ - ...m, - tags: m.tags.join(','), - })) - // ie doesn't like console.table - if (console.table) { - console.table(formatted) - } else { - console.log(formatted) - } - this.metrics = [] - } - - /** - * compact keys for smaller payload - */ - serialize(): CompactMetric[] { - return this.metrics.map((m) => { - return { - m: m.metric, - v: m.value, - t: m.tags, - k: compactMetricType(m.type), - e: m.timestamp, - } - }) + override increment(metric: string, by?: number, tags?: string[]): void { + super.increment(metric, by, tags) + this._remoteMetrics?.increment(metric, tags ?? []) } } diff --git a/packages/browser/src/core/stats/remote-metrics.ts b/packages/browser/src/core/stats/remote-metrics.ts index 8c71b7068..5f1364d42 100644 --- a/packages/browser/src/core/stats/remote-metrics.ts +++ b/packages/browser/src/core/stats/remote-metrics.ts @@ -1,3 +1,4 @@ +import { CoreMetric } from '@segment/analytics-core' import fetch from 'unfetch' import { version } from '../../generated/version' import { getVersionType } from '../../plugins/segmentio/normalize' @@ -9,7 +10,10 @@ export interface MetricsOptions { maxQueueSize?: number } -type Metric = { type: 'Counter'; metric: string; value: number; tags: object } +type RemoteMetric = Omit & { + type: 'counter' + tags: Record +} function logError(err: unknown): void { console.error('Error sending segment performance metrics', err) @@ -21,7 +25,7 @@ export class RemoteMetrics { private maxQueueSize: number sampleRate: number - queue: Metric[] + queue: RemoteMetric[] constructor(options?: MetricsOptions) { this.host = options?.host ?? 'api.segment.io/v1' @@ -85,7 +89,7 @@ export class RemoteMetrics { } this.queue.push({ - type: 'Counter', + type: 'counter', metric, value: 1, tags: formatted, diff --git a/packages/browser/src/core/task/task-group.ts b/packages/browser/src/core/task/task-group.ts deleted file mode 100644 index 63495b11a..000000000 --- a/packages/browser/src/core/task/task-group.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { isThenable } from '../../lib/is-thenable' - -export type TaskGroup = { - done: () => Promise - run: any>( - op: Operation - ) => ReturnType -} - -export const createTaskGroup = (): TaskGroup => { - let taskCompletionPromise: Promise - let resolvePromise: () => void - let count = 0 - - return { - done: () => taskCompletionPromise, - run: (op) => { - const returnValue = op() - - if (isThenable(returnValue)) { - if (++count === 1) { - taskCompletionPromise = new Promise((res) => (resolvePromise = res)) - } - - returnValue.finally(() => --count === 0 && resolvePromise()) - } - - return returnValue - }, - } -} diff --git a/packages/browser/src/core/user/__tests__/index.test.ts b/packages/browser/src/core/user/__tests__/index.test.ts index 1f39c438c..7f78deab7 100644 --- a/packages/browser/src/core/user/__tests__/index.test.ts +++ b/packages/browser/src/core/user/__tests__/index.test.ts @@ -25,6 +25,8 @@ beforeEach(function () { clear() }) +jest.spyOn(console, 'warn').mockImplementation(() => {}) // silence console spam. + describe('user', () => { const cookieKey = User.defaults.cookie.key const localStorageKey = User.defaults.localStorage.key diff --git a/packages/browser/src/lib/priority-queue/index.ts b/packages/browser/src/lib/priority-queue/index.ts index 7b86012eb..4cf31a11f 100644 --- a/packages/browser/src/lib/priority-queue/index.ts +++ b/packages/browser/src/lib/priority-queue/index.ts @@ -1,99 +1,3 @@ -import { Emitter } from '@segment/analytics-core' -import { backoff } from './backoff' +import { PriorityQueue, ON_REMOVE_FROM_FUTURE } from '@segment/analytics-core' -/** - * @internal - */ -export const ON_REMOVE_FROM_FUTURE = 'onRemoveFromFuture' - -export type WithID = { - id: string -} - -export class PriorityQueue extends Emitter { - protected future: T[] = [] - protected queue: T[] - protected seen: Record - - public maxAttempts: number - - constructor(maxAttempts: number, queue: T[], seen?: Record) { - super() - this.maxAttempts = maxAttempts - this.queue = queue - this.seen = seen ?? {} - } - - push(...operations: T[]): boolean[] { - const accepted = operations.map((operation) => { - const attempts = this.updateAttempts(operation) - - if (attempts > this.maxAttempts || this.includes(operation)) { - return false - } - - this.queue.push(operation) - return true - }) - - this.queue = this.queue.sort( - (a, b) => this.getAttempts(a) - this.getAttempts(b) - ) - return accepted - } - - pushWithBackoff(operation: T): boolean { - if (this.getAttempts(operation) === 0) { - return this.push(operation)[0] - } - - const attempt = this.updateAttempts(operation) - - if (attempt > this.maxAttempts || this.includes(operation)) { - return false - } - - const timeout = backoff({ attempt: attempt - 1 }) - - setTimeout(() => { - this.queue.push(operation) - // remove from future list - this.future = this.future.filter((f) => f.id !== operation.id) - // Lets listeners know that a 'future' message is now available in the queue - this.emit(ON_REMOVE_FROM_FUTURE) - }, timeout) - - this.future.push(operation) - return true - } - - public getAttempts(operation: T): number { - return this.seen[operation.id] ?? 0 - } - - public updateAttempts(operation: T): number { - this.seen[operation.id] = this.getAttempts(operation) + 1 - return this.getAttempts(operation) - } - - includes(operation: T): boolean { - return ( - this.queue.includes(operation) || - this.future.includes(operation) || - Boolean(this.queue.find((i) => i.id === operation.id)) || - Boolean(this.future.find((i) => i.id === operation.id)) - ) - } - - pop(): T | undefined { - return this.queue.shift() - } - - public get length(): number { - return this.queue.length - } - - public get todo(): number { - return this.queue.length + this.future.length - } -} +export { PriorityQueue, ON_REMOVE_FROM_FUTURE } diff --git a/packages/browser/src/plugins/ajs-destination/index.ts b/packages/browser/src/plugins/ajs-destination/index.ts index 621831703..5cfebabeb 100644 --- a/packages/browser/src/plugins/ajs-destination/index.ts +++ b/packages/browser/src/plugins/ajs-destination/index.ts @@ -5,8 +5,8 @@ import { LegacySettings } from '../../browser' import { isOffline, isOnline } from '../../core/connection' import { Context, ContextCancelation } from '../../core/context' import { isServer } from '../../core/environment' -import { Plugin } from '../../core/plugin' -import { attempt } from '../../core/queue/delivery' +import { DestinationPlugin, Plugin } from '../../core/plugin' +import { attempt } from '@segment/analytics-core' import { isPlanEventEnabled } from '../../lib/is-plan-event-enabled' import { mergedOptions } from '../../lib/merged-options' import { pWhile } from '../../lib/p-while' @@ -63,7 +63,7 @@ async function flushQueue( return queue } -export class LegacyDestination implements Plugin { +export class LegacyDestination implements DestinationPlugin { name: string version: string settings: JSONObject diff --git a/packages/browser/src/plugins/remote-loader/index.ts b/packages/browser/src/plugins/remote-loader/index.ts index b45e622b7..8fa8566e0 100644 --- a/packages/browser/src/plugins/remote-loader/index.ts +++ b/packages/browser/src/plugins/remote-loader/index.ts @@ -1,7 +1,7 @@ import type { Integrations } from '../../core/events/interfaces' import { LegacySettings } from '../../browser' import { JSONObject, JSONValue } from '../../core/events' -import { Plugin } from '../../core/plugin' +import { DestinationPlugin, Plugin } from '../../core/plugin' import { loadScript } from '../../lib/load-script' import { getCDN } from '../../lib/parse-cdn' import { @@ -24,7 +24,7 @@ export interface RemotePlugin { settings: JSONObject } -export class ActionDestination implements Plugin { +export class ActionDestination implements DestinationPlugin { name: string // destination name version = '1.0.0' type: Plugin['type'] diff --git a/packages/browser/src/plugins/segmentio/__tests__/normalize.test.ts b/packages/browser/src/plugins/segmentio/__tests__/normalize.test.ts index a64d23f7f..5e83069f5 100644 --- a/packages/browser/src/plugins/segmentio/__tests__/normalize.test.ts +++ b/packages/browser/src/plugins/segmentio/__tests__/normalize.test.ts @@ -160,7 +160,7 @@ describe('before loading', () => { it('should add .userAgent', () => { normalize(analytics, object, options, {}) const removeVersionNum = (agent: string) => agent.replace(/jsdom\/.*/, '') - const userAgent1 = removeVersionNum(object.context?.userAgent) + const userAgent1 = removeVersionNum(object.context?.userAgent as string) const userAgent2 = removeVersionNum(navigator.userAgent) assert(userAgent1 === userAgent2) }) @@ -296,7 +296,7 @@ describe('before loading', () => { assert(object) assert(object.context) assert(object.context.referrer) - expect(object.context.referrer.id).toEqual('medium') + expect(object.context.referrer.id).toBe('medium') assert(object.context.referrer.type === 'millennial-media') expect(cookie.get('s:context.referrer')).toEqual( JSON.stringify({ diff --git a/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts b/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts index ad47b2fac..4bb1b601f 100644 --- a/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts +++ b/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts @@ -5,8 +5,8 @@ import { isOffline } from '../../../core/connection' import { Plugin } from '../../../core/plugin' import { pageEnrichment } from '../../page-enrichment' import { scheduleFlush } from '../schedule-flush' -import { PersistedPriorityQueue } from '../../../lib/priority-queue/persisted' -import { PriorityQueue } from '../../../lib/priority-queue' +import * as PPQ from '../../../lib/priority-queue/persisted' +import * as PQ from '../../../lib/priority-queue' import { Context } from '../../../core/context' jest.mock('../schedule-flush') @@ -17,11 +17,11 @@ describe('Segment.io retries', () => { let options: SegmentioSettings let analytics: Analytics let segment: Plugin - let queue: (PersistedPriorityQueue | PriorityQueue) & { + let queue: (PPQ.PersistedPriorityQueue | PQ.PriorityQueue) & { __type?: QueueType } - ;[false, true].forEach((disableClientPersistence) => { - describe(`disableClientPersistence: ${disableClientPersistence}`, () => { + ;[false, true].forEach((persistenceIsDisabled) => { + describe(`disableClientPersistence: ${persistenceIsDisabled}`, () => { beforeEach(async () => { jest.resetAllMocks() jest.restoreAllMocks() @@ -32,20 +32,26 @@ describe('Segment.io retries', () => { options = { apiKey: 'foo' } analytics = new Analytics( { writeKey: options.apiKey }, - { retryQueue: true, disableClientPersistence } + { + retryQueue: true, + disableClientPersistence: persistenceIsDisabled, + } ) - queue = disableClientPersistence - ? new PriorityQueue(3, []) - : new PersistedPriorityQueue(3, `test-Segment.io`) - - queue['__type'] = disableClientPersistence ? 'priority' : 'persisted' - if (disableClientPersistence) { - // @ts-expect-error reassign import - PriorityQueue = jest.fn().mockImplementation(() => queue) + if (persistenceIsDisabled) { + queue = new PQ.PriorityQueue(3, []) + queue['__type'] = 'priority' + Object.defineProperty(PQ, 'PriorityQueue', { + writable: true, + value: jest.fn().mockImplementation(() => queue), + }) } else { - // @ts-expect-error reassign import - PersistedPriorityQueue = jest.fn().mockImplementation(() => queue) + queue = new PPQ.PersistedPriorityQueue(3, `test-Segment.io`) + queue['__type'] = 'persisted' + Object.defineProperty(PPQ, 'PersistedPriorityQueue', { + writable: true, + value: jest.fn().mockImplementation(() => queue), + }) } segment = segmentio(analytics, options, {}) @@ -65,7 +71,7 @@ describe('Segment.io retries', () => { expect(ctx.attempts).toBe(1) expect(isOffline).toHaveBeenCalledTimes(2) expect(queue.__type).toBe( - disableClientPersistence ? 'priority' : 'persisted' + persistenceIsDisabled ? 'priority' : 'persisted' ) }) }) diff --git a/packages/browser/src/plugins/segmentio/schedule-flush.ts b/packages/browser/src/plugins/segmentio/schedule-flush.ts index acaa01902..e127119c3 100644 --- a/packages/browser/src/plugins/segmentio/schedule-flush.ts +++ b/packages/browser/src/plugins/segmentio/schedule-flush.ts @@ -1,7 +1,7 @@ import { isOffline } from '../../core/connection' import { Context } from '../../core/context' import { Plugin } from '../../core/plugin' -import { attempt } from '../../core/queue/delivery' +import { attempt } from '@segment/analytics-core' import { pWhile } from '../../lib/p-while' import { PriorityQueue } from '../../lib/priority-queue' diff --git a/packages/browser/webpack.config.js b/packages/browser/webpack.config.js index a21131032..6733cdccb 100644 --- a/packages/browser/webpack.config.js +++ b/packages/browser/webpack.config.js @@ -25,7 +25,9 @@ if (process.env.ANALYZE) { plugins.push(new BundleAnalyzerPlugin()) } +/** @type { import('webpack').Configuration } */ const config = { + stats: process.env.WATCH === 'true' ? 'errors-warnings' : 'normal', node: { global: false, // do not polyfill global object, we can use getGlobal function if needed. }, diff --git a/packages/core-integration-tests/src/public-api.test.ts b/packages/core-integration-tests/src/public-api.test.ts index 1d6f3f5c0..6018b355c 100644 --- a/packages/core-integration-tests/src/public-api.test.ts +++ b/packages/core-integration-tests/src/public-api.test.ts @@ -1,6 +1,8 @@ import { CoreContext } from '@segment/analytics-core' +class TestCtx extends CoreContext {} + it('should be able to import and instantiate some module from core', () => { // Test the ability to do basic imports - expect(typeof new CoreContext({ type: 'alias' })).toBe('object') + expect(typeof new TestCtx({ type: 'alias' })).toBe('object') }) diff --git a/packages/core/package.json b/packages/core/package.json index 6126e110d..c73095c4a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,7 +23,7 @@ "build": "yarn concurrently 'yarn:build:*'", "build:esm": "yarn tsc -p tsconfig.build.json", "build:cjs": "yarn tsc -p tsconfig.build.json --outDir ./dist/cjs --module commonjs", - "watch": "yarn build:esm --watch --incremental", + "watch": "yarn build:esm --watch", "watch:test": "yarn test --watch", "tsc": "yarn run -T tsc", "eslint": "yarn run -T eslint", diff --git a/packages/core/src/analytics/__tests__/dispatch.test.ts b/packages/core/src/analytics/__tests__/dispatch.test.ts index 9f3c8d963..f9c1b35a2 100644 --- a/packages/core/src/analytics/__tests__/dispatch.test.ts +++ b/packages/core/src/analytics/__tests__/dispatch.test.ts @@ -26,81 +26,58 @@ jest.mock('../../callback', () => ({ invokeCallback: invokeCallback, })) -import { EventQueue } from '../../queue/event-queue' +import { CoreEventQueue } from '../../queue/event-queue' import { Emitter } from '../../emitter' import { dispatch, getDelay } from '../dispatch' -import { PriorityQueue } from '../../priority-queue' import { CoreContext } from '../../context' +import { TestCtx, TestEventQueue } from '../../../test-helpers' let emitter!: Emitter -let queue!: EventQueue -const dispatchSingleSpy = jest.spyOn(EventQueue.prototype, 'dispatchSingle') -const dispatchSpy = jest.spyOn(EventQueue.prototype, 'dispatch') +let queue!: CoreEventQueue +const dispatchSingleSpy = jest.spyOn(CoreEventQueue.prototype, 'dispatchSingle') +const dispatchSpy = jest.spyOn(CoreEventQueue.prototype, 'dispatch') const screenCtxMatcher = expect.objectContaining>({ event: { type: 'screen' }, }) + +const screenCtx = new TestCtx({ type: 'screen' }) describe('Dispatch', () => { beforeEach(() => { jest.resetAllMocks() dispatchSingleSpy.mockImplementationOnce((ctx) => Promise.resolve(ctx)) invokeCallback.mockImplementationOnce((ctx) => Promise.resolve(ctx)) dispatchSpy.mockImplementationOnce((ctx) => Promise.resolve(ctx)) - queue = new EventQueue(new PriorityQueue(4, [])) + queue = new TestEventQueue() queue.isEmpty = jest.fn().mockReturnValue(false) emitter = new Emitter() }) - it('should not dispatch if client is currently offline and retries are *disabled* for the main event queue', async () => { - isOnline.mockReturnValue(false) - isOffline.mockReturnValue(true) - - const ctx = await dispatch({ type: 'screen' }, queue, emitter, { - retryQueue: false, - }) - expect(ctx).toEqual(screenCtxMatcher) - const called = Boolean( - dispatchSingleSpy.mock.calls.length || dispatchSpy.mock.calls.length - ) - expect(called).toBeFalsy() - }) - - it('should be allowed to dispatch if client is currently offline and retries are *enabled* for the main event queue', async () => { - isOnline.mockReturnValue(false) - isOffline.mockReturnValue(true) - - await dispatch({ type: 'screen' }, queue, emitter, { - retryQueue: true, - }) - const called = Boolean( - dispatchSingleSpy.mock.calls.length || dispatchSpy.mock.calls.length - ) - expect(called).toBeTruthy() - }) - it('should call dispatchSingle correctly if queue is empty', async () => { queue.isEmpty = jest.fn().mockReturnValue(true) - await dispatch({ type: 'screen' }, queue, emitter) + await dispatch(screenCtx, queue, emitter) expect(dispatchSingleSpy).toBeCalledWith(screenCtxMatcher) expect(dispatchSpy).not.toBeCalled() }) it('should call dispatch correctly if queue has items', async () => { - await dispatch({ type: 'screen' }, queue, emitter) + await dispatch(screenCtx, queue, emitter) expect(dispatchSpy).toBeCalledWith(screenCtxMatcher) expect(dispatchSingleSpy).not.toBeCalled() }) it('should only call invokeCallback if callback is passed', async () => { - await dispatch({ type: 'screen' }, queue, emitter) + await dispatch(screenCtx, queue, emitter) expect(invokeCallback).not.toBeCalled() const cb = jest.fn() - await dispatch({ type: 'screen' }, queue, emitter, { callback: cb }) + await dispatch(screenCtx, queue, emitter, { + callback: cb, + }) expect(invokeCallback).toBeCalledTimes(1) }) it('should call invokeCallback with correct args', async () => { const cb = jest.fn() - await dispatch({ type: 'screen' }, queue, emitter, { + await dispatch(screenCtx, queue, emitter, { callback: cb, }) expect(dispatchSpy).toBeCalledWith(screenCtxMatcher) diff --git a/packages/core/src/analytics/dispatch.ts b/packages/core/src/analytics/dispatch.ts index fbfa7e62c..182af351f 100644 --- a/packages/core/src/analytics/dispatch.ts +++ b/packages/core/src/analytics/dispatch.ts @@ -1,15 +1,13 @@ import { CoreContext } from '../context' -import { CoreSegmentEvent, Callback } from '../events/interfaces' -import { EventQueue } from '../queue/event-queue' -import { isOffline } from '../connection' +import { Callback } from '../events/interfaces' +import { CoreEventQueue } from '../queue/event-queue' import { invokeCallback } from '../callback' import { Emitter } from '../emitter' -export type DispatchOptions = { +export type DispatchOptions = { timeout?: number debug?: boolean - callback?: Callback - retryQueue?: boolean + callback?: Callback } /* The amount of time in ms to wait before invoking the callback. */ @@ -26,21 +24,19 @@ export const getDelay = (startTimeInEpochMS: number, timeoutInMS?: number) => { * @param emitter - This is typically an instance of "Analytics" -- used for metrics / progress information. * @param options */ -export async function dispatch( - event: CoreSegmentEvent, - queue: EventQueue, +export async function dispatch< + Ctx extends CoreContext, + EQ extends CoreEventQueue +>( + ctx: Ctx, + queue: EQ, emitter: Emitter, - options?: DispatchOptions -): Promise { - const ctx = new CoreContext(event) + options?: DispatchOptions +): Promise { emitter.emit('dispatch_start', ctx) - if (isOffline() && !options?.retryQueue) { - return ctx - } - const startTime = Date.now() - let dispatched: CoreContext + let dispatched: Ctx if (queue.isEmpty()) { dispatched = await queue.dispatchSingle(ctx) } else { diff --git a/packages/core/src/callback/__tests__/index.test.ts b/packages/core/src/callback/__tests__/index.test.ts index 8ceebf2d8..4bdf2a7f7 100644 --- a/packages/core/src/callback/__tests__/index.test.ts +++ b/packages/core/src/callback/__tests__/index.test.ts @@ -1,5 +1,5 @@ import { invokeCallback } from '..' -import { CoreContext } from '../../context' +import { TestCtx } from '../../../test-helpers' describe(invokeCallback, () => { afterEach(() => { @@ -7,7 +7,7 @@ describe(invokeCallback, () => { }) it('invokes a callback asynchronously', async () => { - const ctx = new CoreContext({ + const ctx = new TestCtx({ type: 'track', }) @@ -22,7 +22,7 @@ describe(invokeCallback, () => { // A.JS classic waited for the timeout/delay before invoking callback, // so keep same behavior in A.JS next. it('calls the callback after a delay', async () => { - const ctx = new CoreContext({ + const ctx = new TestCtx({ type: 'track', }) @@ -39,11 +39,11 @@ describe(invokeCallback, () => { }) it('ignores the callback if it takes too long to resolve', async () => { - const ctx = new CoreContext({ + const ctx = new TestCtx({ type: 'track', }) - const slow = (_ctx: CoreContext): Promise => { + const slow = (_ctx: TestCtx): Promise => { return new Promise((resolve) => { setTimeout(resolve, 1100) }) @@ -63,11 +63,11 @@ describe(invokeCallback, () => { }) it('does not crash if the callback crashes', async () => { - const ctx = new CoreContext({ + const ctx = new TestCtx({ type: 'track', }) - const boo = (_ctx: CoreContext): Promise => { + const boo = (_ctx: TestCtx): Promise => { throw new Error('👻 boo!') } diff --git a/packages/core/src/callback/index.ts b/packages/core/src/callback/index.ts index 864f74802..8bc1156cd 100644 --- a/packages/core/src/callback/index.ts +++ b/packages/core/src/callback/index.ts @@ -25,11 +25,11 @@ export function sleep(timeoutInMs: number): Promise { * @param callback - the function to invoke * @param delay - aka "timeout". The amount of time in ms to wait before invoking the callback. */ -export function invokeCallback( - ctx: CoreContext, - callback: Callback, +export function invokeCallback( + ctx: Ctx, + callback: Callback, delay: number -): Promise { +): Promise { const cb = () => { try { return Promise.resolve(callback(ctx)) @@ -44,7 +44,7 @@ export function invokeCallback( .then(() => pTimeout(cb(), 1000)) .catch((err) => { ctx?.log('warn', 'Callback Error', { error: err }) - ctx?.stats?.increment('callback_error') + ctx?.stats.increment('callback_error') }) .then(() => ctx) ) diff --git a/packages/core/src/context/index.ts b/packages/core/src/context/index.ts index 3834da010..53c334542 100644 --- a/packages/core/src/context/index.ts +++ b/packages/core/src/context/index.ts @@ -3,13 +3,13 @@ import { CoreSegmentEvent } from '../events/interfaces' import { v4 as uuid } from '@lukeed/uuid' import { dset } from 'dset' import { CoreLogger, LogLevel, LogMessage } from '../logger' -import Stats, { Metric } from '../stats' +import { CoreStats, CoreMetric, NullStats } from '../stats' export interface SerializedContext { id: string event: CoreSegmentEvent logs: LogMessage[] - metrics?: Metric[] + metrics?: CoreMetric[] } export interface ContextFailedDelivery { @@ -34,10 +34,12 @@ export class ContextCancelation { } } -export class CoreContext { +export abstract class CoreContext< + Event extends CoreSegmentEvent = CoreSegmentEvent +> { event: Event logger: CoreLogger - stats?: Stats + stats: CoreStats attempts = 0 private _failedDelivery?: ContextFailedDelivery @@ -46,8 +48,8 @@ export class CoreContext { constructor( event: Event, id = uuid(), - stats?: Stats, - logger: CoreLogger = new CoreLogger() + stats: CoreStats = new NullStats(), + logger = new CoreLogger() ) { this.event = event this._id = id @@ -55,15 +57,15 @@ export class CoreContext { this.stats = stats } - static system(): CoreContext { - return new CoreContext({ type: 'track', event: 'system' }) + static system(): void { + // This should be overridden by the subclass to return an instance of the subclass. } isSame(other: CoreContext): boolean { return other.id === this.id } - cancel = (error?: Error | ContextCancelation): never => { + cancel(error?: Error | ContextCancelation): never { if (error) { throw error } @@ -107,7 +109,7 @@ export class CoreContext { flush(): void { this.logger.flush() - this.stats?.flush() + this.stats.flush() } toJSON(): SerializedContext { @@ -115,7 +117,7 @@ export class CoreContext { id: this._id, event: this.event, logs: this.logger.logs, - metrics: this.stats?.metrics, + metrics: this.stats.metrics, } } } diff --git a/packages/core/src/events/interfaces.ts b/packages/core/src/events/interfaces.ts index 0d8f0c7a1..10fc12d62 100644 --- a/packages/core/src/events/interfaces.ts +++ b/packages/core/src/events/interfaces.ts @@ -1,7 +1,9 @@ import { CoreContext } from '../context' import { ID } from '../user' -export type Callback = (ctx: CoreContext) => Promise | unknown +export type Callback = ( + ctx: Ctx +) => Promise | unknown export type SegmentEventType = | 'track' @@ -162,6 +164,7 @@ export interface CoreExtraContext { url?: string link?: string + id?: string // undocumented btid?: string // undocumented? urid?: string // undocumented? } @@ -218,8 +221,13 @@ export interface SegmentEventMetadata { export type Timestamp = Date | string +/** + * A Plan allows users to specify events and which destinations they would like them to be sent to + */ export interface Plan { track?: TrackPlan + identify?: TrackPlan + group?: TrackPlan } export interface TrackPlan { @@ -228,7 +236,7 @@ export interface TrackPlan { __default?: PlanEvent } -interface PlanEvent { +export interface PlanEvent { /** * Whether or not this plan event is enabled */ @@ -236,7 +244,7 @@ interface PlanEvent { /** * Which integrations the plan event applies to */ - integrations: { + integrations?: { [key: string]: boolean } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c743819e5..865b14804 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,3 +14,5 @@ export * from './validation/helpers' export * from './validation/assertions' export * from './utils/bind-all' export * from './stats' +export { CoreLogger } from './logger' +export * from './queue/delivery' diff --git a/packages/core/src/logger/index.ts b/packages/core/src/logger/index.ts index fb1f6a33a..c6b1b59d3 100644 --- a/packages/core/src/logger/index.ts +++ b/packages/core/src/logger/index.ts @@ -6,17 +6,16 @@ export type LogMessage = { extras?: Record } -// interface is just for clarity -export interface Logger { +export interface GenericLogger { log(level: LogLevel, message: string, extras?: object): void flush(): void logs: LogMessage[] } -export class CoreLogger implements Logger { +export class CoreLogger implements GenericLogger { private _logs: LogMessage[] = [] - log = (level: LogLevel, message: string, extras?: object): void => { + log(level: LogLevel, message: string, extras?: object) { const time = new Date() this._logs.push({ level, diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index 602c950e1..0978acb96 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -1,8 +1,7 @@ -import { CoreAnalytics } from '../analytics' -import { CoreContext } from '../context' +import type { CoreAnalytics } from '../analytics' +import type { CoreContext } from '../context' interface CorePluginConfig { - // eslint-disable-next-line @typescript-eslint/no-explicit-any options: any priority: 'critical' | 'non-critical' // whether AJS should expect this plugin to be loaded before starting event delivery } @@ -19,10 +18,11 @@ export type PluginType = // utility - do not affect lifecycle. Should be run and executed once. Their `track/identify` calls don't really do anything. example export interface CorePlugin< - Analytics extends CoreAnalytics = CoreAnalytics, - Ctx extends CoreContext = CoreContext + Ctx extends CoreContext = CoreContext, + Analytics extends CoreAnalytics = any > { name: string + alternativeNames?: string[] version: string type: PluginType isLoaded: () => boolean diff --git a/packages/core/src/priority-queue/index.ts b/packages/core/src/priority-queue/index.ts index 7a182af05..49932ea11 100644 --- a/packages/core/src/priority-queue/index.ts +++ b/packages/core/src/priority-queue/index.ts @@ -1,4 +1,3 @@ -import { CoreContext } from '../context' import { Emitter } from '../emitter' import { backoff } from './backoff' @@ -7,22 +6,20 @@ import { backoff } from './backoff' */ export const ON_REMOVE_FROM_FUTURE = 'onRemoveFromFuture' -export interface WithID { +interface QueueItem { id: string } -export class PriorityQueue< - Context extends WithID = CoreContext -> extends Emitter { - protected future: Context[] = [] - protected queue: Context[] +export class PriorityQueue extends Emitter { + protected future: Item[] = [] + protected queue: Item[] protected seen: Record public maxAttempts: number constructor( maxAttempts: number, - queue: Context[], + queue: Item[], seen?: Record ) { super() @@ -31,8 +28,8 @@ export class PriorityQueue< this.seen = seen ?? {} } - push(...ctx: Context[]): boolean[] { - const accepted = ctx.map((operation) => { + push(...items: Item[]): boolean[] { + const accepted = items.map((operation) => { const attempts = this.updateAttempts(operation) if (attempts > this.maxAttempts || this.includes(operation)) { @@ -49,50 +46,50 @@ export class PriorityQueue< return accepted } - pushWithBackoff(ctx: Context): boolean { - if (this.getAttempts(ctx) === 0) { - return this.push(ctx)[0] + pushWithBackoff(item: Item): boolean { + if (this.getAttempts(item) === 0) { + return this.push(item)[0] } - const attempt = this.updateAttempts(ctx) + const attempt = this.updateAttempts(item) - if (attempt > this.maxAttempts || this.includes(ctx)) { + if (attempt > this.maxAttempts || this.includes(item)) { return false } const timeout = backoff({ attempt: attempt - 1 }) setTimeout(() => { - this.queue.push(ctx) + this.queue.push(item) // remove from future list - this.future = this.future.filter((f) => f.id !== ctx.id) + this.future = this.future.filter((f) => f.id !== item.id) // Lets listeners know that a 'future' message is now available in the queue this.emit(ON_REMOVE_FROM_FUTURE) }, timeout) - this.future.push(ctx) + this.future.push(item) return true } - public getAttempts(ctx: Context): number { - return this.seen[ctx.id] ?? 0 + public getAttempts(item: Item): number { + return this.seen[item.id] ?? 0 } - public updateAttempts(ctx: Context): number { - this.seen[ctx.id] = this.getAttempts(ctx) + 1 - return this.getAttempts(ctx) + public updateAttempts(item: Item): number { + this.seen[item.id] = this.getAttempts(item) + 1 + return this.getAttempts(item) } - includes(ctx: Context): boolean { + includes(item: Item): boolean { return ( - this.queue.includes(ctx) || - this.future.includes(ctx) || - Boolean(this.queue.find((i) => i.id === ctx.id)) || - Boolean(this.future.find((i) => i.id === ctx.id)) + this.queue.includes(item) || + this.future.includes(item) || + Boolean(this.queue.find((i) => i.id === item.id)) || + Boolean(this.future.find((i) => i.id === item.id)) ) } - pop(): Context | undefined { + pop(): Item | undefined { return this.queue.shift() } diff --git a/packages/core/src/queue/__tests__/event-queue.test.ts b/packages/core/src/queue/__tests__/event-queue.test.ts index 920bb110c..34a887b9f 100644 --- a/packages/core/src/queue/__tests__/event-queue.test.ts +++ b/packages/core/src/queue/__tests__/event-queue.test.ts @@ -3,19 +3,12 @@ import { noop } from 'lodash' import { CoreAnalytics } from '../../analytics' import { pWhile } from '../../utils/p-while' import * as timer from '../../priority-queue/backoff' -import { CoreContext, ContextCancelation } from '../../context' +import { ContextCancelation } from '../../context' import { CorePlugin } from '../../plugins' import { pTimeout } from '../../callback' -import { EventQueue as EQ } from '../event-queue' -import { PriorityQueue } from '../../priority-queue' +import { TestCtx, TestEventQueue } from '../../../test-helpers' -class EventQueue extends EQ { - constructor() { - super(new PriorityQueue(4, [])) - } -} - -async function flushAll(eq: EventQueue): Promise { +async function flushAll(eq: TestEventQueue): Promise { const flushSpy = jest.spyOn(eq, 'flush') await pWhile( () => eq.queue.length > 0, @@ -42,10 +35,10 @@ const testPlugin: CorePlugin = { const ajs = {} as CoreAnalytics -let fruitBasket: CoreContext, basketView: CoreContext, shopper: CoreContext +let fruitBasket: TestCtx, basketView: TestCtx, shopper: TestCtx beforeEach(() => { - fruitBasket = new CoreContext({ + fruitBasket = new TestCtx({ type: 'track', event: 'Fruit Basket', properties: { @@ -55,11 +48,11 @@ beforeEach(() => { }, }) - basketView = new CoreContext({ + basketView = new TestCtx({ type: 'page', }) - shopper = new CoreContext({ + shopper = new TestCtx({ type: 'identify', traits: { name: 'Netto Farah', @@ -68,7 +61,7 @@ beforeEach(() => { }) test('can send events', async () => { - const eq = new EventQueue() + const eq = new TestEventQueue() const evt = await eq.dispatch(fruitBasket) expect(evt).toBe(fruitBasket) }) @@ -76,7 +69,7 @@ test('can send events', async () => { test('delivers events out of band', async () => { jest.useFakeTimers() - const eq = new EventQueue() + const eq = new TestEventQueue() // eslint-disable-next-line @typescript-eslint/no-floating-promises eq.dispatch(fruitBasket) @@ -94,9 +87,9 @@ test('delivers events out of band', async () => { test('does not enqueue multiple flushes at once', async () => { jest.useFakeTimers() - const eq = new EventQueue() + const eq = new TestEventQueue() - const anothaOne = new CoreContext({ + const anothaOne = new TestCtx({ type: 'page', }) @@ -122,7 +115,7 @@ describe('Flushing', () => { }) test('works until the queue is empty', async () => { - const eq = new EventQueue() + const eq = new TestEventQueue() eq.dispatch(fruitBasket) eq.dispatch(basketView) @@ -137,10 +130,10 @@ describe('Flushing', () => { }) test('re-queues failed events', async () => { - const eq = new EventQueue() + const eq = new TestEventQueue() await eq.register( - CoreContext.system(), + TestCtx.system(), { ...testPlugin, track: (ctx) => { @@ -172,7 +165,7 @@ describe('Flushing', () => { test('waits for critical tasks to finish before performing event deliveries', async () => { jest.useRealTimers() - const eq = new EventQueue() + const eq = new TestEventQueue() let finishCriticalTask: () => void = noop const startTask = () => @@ -206,10 +199,10 @@ describe('Flushing', () => { // make sure all backoffs return immediatelly jest.spyOn(timer, 'backoff').mockImplementationOnce(() => 100) - const eq = new EventQueue() + const eq = new TestEventQueue() await eq.register( - CoreContext.system(), + TestCtx.system(), { ...testPlugin, track: (ctx) => { @@ -246,10 +239,10 @@ describe('Flushing', () => { }) test('does not retry non retriable cancelations', async () => { - const eq = new EventQueue() + const eq = new TestEventQueue() await eq.register( - CoreContext.system(), + TestCtx.system(), { ...testPlugin, track: async (ctx) => { @@ -283,10 +276,10 @@ describe('Flushing', () => { }) test('does not retry non retriable cancelations (dispatchSingle)', async () => { - const eq = new EventQueue() + const eq = new TestEventQueue() await eq.register( - CoreContext.system(), + TestCtx.system(), { ...testPlugin, track: async (ctx) => { @@ -308,10 +301,10 @@ describe('Flushing', () => { // make sure all backoffs return immediatelly jest.spyOn(timer, 'backoff').mockImplementationOnce(() => 100) - const eq = new EventQueue() + const eq = new TestEventQueue() await eq.register( - CoreContext.system(), + TestCtx.system(), { ...testPlugin, track: (ctx) => { @@ -349,10 +342,10 @@ describe('Flushing', () => { test('client: can block on delivery', async () => { jest.useRealTimers() - const eq = new EventQueue() + const eq = new TestEventQueue() await eq.register( - CoreContext.system(), + TestCtx.system(), { ...testPlugin, track: (ctx) => { @@ -391,7 +384,7 @@ describe('Flushing', () => { ...testPlugin, name: 'Amplitude', type: 'destination' as const, - track: (ctx: CoreContext): Promise | CoreContext => { + track: (ctx: TestCtx): Promise | TestCtx => { return Promise.resolve(ctx) }, } @@ -400,7 +393,7 @@ describe('Flushing', () => { ...testPlugin, name: 'Mixpanel', type: 'destination' as const, - track: (ctx: CoreContext): Promise | CoreContext => { + track: (ctx: TestCtx): Promise | TestCtx => { return Promise.resolve(ctx) }, } @@ -409,13 +402,13 @@ describe('Flushing', () => { ...testPlugin, name: 'Segment.io', type: 'after' as const, - track: (ctx: CoreContext): Promise | CoreContext => { + track: (ctx: TestCtx): Promise | TestCtx => { return Promise.resolve(ctx) }, } test('does not delivery to destinations on denyList', async () => { - const eq = new EventQueue() + const eq = new TestEventQueue() jest.spyOn(amplitude, 'track') jest.spyOn(mixPanel, 'track') @@ -429,11 +422,11 @@ describe('Flushing', () => { }, } - const ctx = new CoreContext(evt) + const ctx = new TestCtx(evt) - await eq.register(CoreContext.system(), amplitude, ajs) - await eq.register(CoreContext.system(), mixPanel, ajs) - await eq.register(CoreContext.system(), segmentio, ajs) + await eq.register(TestCtx.system(), amplitude, ajs) + await eq.register(TestCtx.system(), mixPanel, ajs) + await eq.register(TestCtx.system(), segmentio, ajs) eq.dispatch(ctx) @@ -449,7 +442,7 @@ describe('Flushing', () => { }) test('does not deliver to any destination except Segment.io if All: false ', async () => { - const eq = new EventQueue() + const eq = new TestEventQueue() jest.spyOn(amplitude, 'track') jest.spyOn(mixPanel, 'track') @@ -462,11 +455,11 @@ describe('Flushing', () => { }, } - const ctx = new CoreContext(evt) + const ctx = new TestCtx(evt) - await eq.register(CoreContext.system(), amplitude, ajs) - await eq.register(CoreContext.system(), mixPanel, ajs) - await eq.register(CoreContext.system(), segmentio, ajs) + await eq.register(TestCtx.system(), amplitude, ajs) + await eq.register(TestCtx.system(), mixPanel, ajs) + await eq.register(TestCtx.system(), segmentio, ajs) eq.dispatch(ctx) @@ -481,7 +474,7 @@ describe('Flushing', () => { }) test('does not deliver when All: false and destination is also explicitly false', async () => { - const eq = new EventQueue() + const eq = new TestEventQueue() jest.spyOn(amplitude, 'track') jest.spyOn(mixPanel, 'track') @@ -496,11 +489,11 @@ describe('Flushing', () => { }, } - const ctx = new CoreContext(evt) + const ctx = new TestCtx(evt) - await eq.register(CoreContext.system(), amplitude, ajs) - await eq.register(CoreContext.system(), mixPanel, ajs) - await eq.register(CoreContext.system(), segmentio, ajs) + await eq.register(TestCtx.system(), amplitude, ajs) + await eq.register(TestCtx.system(), mixPanel, ajs) + await eq.register(TestCtx.system(), segmentio, ajs) eq.dispatch(ctx) @@ -515,7 +508,7 @@ describe('Flushing', () => { }) test('delivers to destinations if All: false but the destination is allowed', async () => { - const eq = new EventQueue() + const eq = new TestEventQueue() jest.spyOn(amplitude, 'track') jest.spyOn(mixPanel, 'track') @@ -530,11 +523,11 @@ describe('Flushing', () => { }, } - const ctx = new CoreContext(evt) + const ctx = new TestCtx(evt) - await eq.register(CoreContext.system(), amplitude, ajs) - await eq.register(CoreContext.system(), mixPanel, ajs) - await eq.register(CoreContext.system(), segmentio, ajs) + await eq.register(TestCtx.system(), amplitude, ajs) + await eq.register(TestCtx.system(), mixPanel, ajs) + await eq.register(TestCtx.system(), segmentio, ajs) eq.dispatch(ctx) @@ -549,7 +542,7 @@ describe('Flushing', () => { }) test('delivers to Segment.io if All: false but Segment.io is not specified', async () => { - const eq = new EventQueue() + const eq = new TestEventQueue() jest.spyOn(amplitude, 'track') jest.spyOn(mixPanel, 'track') @@ -563,11 +556,11 @@ describe('Flushing', () => { }, } - const ctx = new CoreContext(evt) + const ctx = new TestCtx(evt) - await eq.register(CoreContext.system(), amplitude, ajs) - await eq.register(CoreContext.system(), mixPanel, ajs) - await eq.register(CoreContext.system(), segmentio, ajs) + await eq.register(TestCtx.system(), amplitude, ajs) + await eq.register(TestCtx.system(), mixPanel, ajs) + await eq.register(TestCtx.system(), segmentio, ajs) eq.dispatch(ctx) @@ -582,7 +575,7 @@ describe('Flushing', () => { }) test('delivers to destinations that exist as an object', async () => { - const eq = new EventQueue() + const eq = new TestEventQueue() jest.spyOn(amplitude, 'track') jest.spyOn(segmentio, 'track') @@ -598,10 +591,10 @@ describe('Flushing', () => { }, } - const ctx = new CoreContext(evt) + const ctx = new TestCtx(evt) - await eq.register(CoreContext.system(), amplitude, ajs) - await eq.register(CoreContext.system(), segmentio, ajs) + await eq.register(TestCtx.system(), amplitude, ajs) + await eq.register(TestCtx.system(), segmentio, ajs) eq.dispatch(ctx) @@ -618,31 +611,27 @@ describe('Flushing', () => { describe('deregister', () => { it('remove plugin from plugins list', async () => { - const eq = new EventQueue() + const eq = new TestEventQueue() const toBeRemoved = { ...testPlugin, name: 'remove-me' } const plugins = [testPlugin, toBeRemoved] - const promises = plugins.map((p) => - eq.register(CoreContext.system(), p, ajs) - ) + const promises = plugins.map((p) => eq.register(TestCtx.system(), p, ajs)) await Promise.all(promises) - await eq.deregister(CoreContext.system(), toBeRemoved, ajs) + await eq.deregister(TestCtx.system(), toBeRemoved, ajs) expect(eq.plugins.length).toBe(1) expect(eq.plugins[0]).toBe(testPlugin) }) it('invokes plugin.unload', async () => { - const eq = new EventQueue() + const eq = new TestEventQueue() const toBeRemoved = { ...testPlugin, name: 'remove-me', unload: jest.fn() } const plugins = [testPlugin, toBeRemoved] - const promises = plugins.map((p) => - eq.register(CoreContext.system(), p, ajs) - ) + const promises = plugins.map((p) => eq.register(TestCtx.system(), p, ajs)) await Promise.all(promises) - await eq.deregister(CoreContext.system(), toBeRemoved, ajs) + await eq.deregister(TestCtx.system(), toBeRemoved, ajs) expect(toBeRemoved.unload).toHaveBeenCalled() expect(eq.plugins.length).toBe(1) expect(eq.plugins[0]).toBe(testPlugin) @@ -651,7 +640,7 @@ describe('deregister', () => { describe('dispatchSingle', () => { it('dispatches events without placing them on the queue', async () => { - const eq = new EventQueue() + const eq = new TestEventQueue() const promise = eq.dispatchSingle(fruitBasket) expect(eq.queue.length).toBe(0) @@ -661,9 +650,9 @@ describe('dispatchSingle', () => { it.skip('records delivery metrics', async () => { // Skip because we don't support metrics atm - const eq = new EventQueue() + const eq = new TestEventQueue() const ctx = await eq.dispatchSingle( - new CoreContext({ + new TestCtx({ type: 'track', }) ) @@ -675,7 +664,7 @@ describe('dispatchSingle', () => { ] `) - expect(ctx.stats?.metrics.map((m) => m.metric)).toMatchInlineSnapshot(` + expect(ctx.stats.metrics.map((m) => m.metric)).toMatchInlineSnapshot(` Array [ "message_dispatched", "message_delivered", @@ -688,10 +677,10 @@ describe('dispatchSingle', () => { // make sure all backoffs return immediatelly jest.spyOn(timer, 'backoff').mockImplementationOnce(() => 100) - const eq = new EventQueue() + const eq = new TestEventQueue() await eq.register( - CoreContext.system(), + TestCtx.system(), { ...testPlugin, track: (ctx) => { diff --git a/packages/core/src/queue/__tests__/extension-flushing.test.ts b/packages/core/src/queue/__tests__/extension-flushing.test.ts index 3b723ae20..d94de8118 100644 --- a/packages/core/src/queue/__tests__/extension-flushing.test.ts +++ b/packages/core/src/queue/__tests__/extension-flushing.test.ts @@ -1,16 +1,17 @@ import { shuffle } from 'lodash' import { CoreAnalytics } from '../../analytics' import { PriorityQueue } from '../../priority-queue' -import { CoreContext } from '../../context' import { CorePlugin as Plugin } from '../../plugins' -import { EventQueue as EQ } from '../event-queue' +import { CoreEventQueue } from '../event-queue' +import { TestCtx } from '../../../test-helpers' -class EventQueue extends EQ { +class EventQueue extends CoreEventQueue { constructor() { super(new PriorityQueue(4, [])) } } -const fruitBasket = new CoreContext({ + +const fruitBasket = new TestCtx({ type: 'track', event: 'Fruit Basket', properties: { @@ -46,7 +47,7 @@ describe('Registration', () => { isLoaded: () => true, } - const ctx = CoreContext.system() + const ctx = TestCtx.system() await eq.register(ctx, plugin, ajs) expect(load).toHaveBeenCalledWith(ctx, ajs) @@ -63,7 +64,7 @@ describe('Registration', () => { isLoaded: () => false, } - const ctx = CoreContext.system() + const ctx = TestCtx.system() await expect( eq.register(ctx, plugin, ajs) ).rejects.toThrowErrorMatchingInlineSnapshot(`"👻"`) @@ -80,7 +81,7 @@ describe('Registration', () => { isLoaded: () => false, } - const ctx = CoreContext.system() + const ctx = TestCtx.system() await eq.register(ctx, plugin, ajs) expect(ctx.logs()[0].level).toEqual('warn') @@ -96,7 +97,7 @@ describe('Plugin flushing', () => { eq.queue = queue await eq.register( - CoreContext.system(), + TestCtx.system(), { ...testPlugin, type: 'before', @@ -108,7 +109,7 @@ describe('Plugin flushing', () => { expect(flushed.logs().map((l) => l.message)).toContain('Delivered') await eq.register( - CoreContext.system(), + TestCtx.system(), { ...testPlugin, name: 'Faulty before', @@ -120,9 +121,9 @@ describe('Plugin flushing', () => { ajs ) - const failedFlush: CoreContext = await eq + const failedFlush: TestCtx = await eq .dispatch( - new CoreContext({ + new TestCtx({ type: 'track', }) ) @@ -137,7 +138,7 @@ describe('Plugin flushing', () => { const eq = new EventQueue() await eq.register( - CoreContext.system(), + TestCtx.system(), { ...testPlugin, name: 'Faulty enrichment', @@ -150,7 +151,7 @@ describe('Plugin flushing', () => { ) const flushed = await eq.dispatch( - new CoreContext({ + new TestCtx({ type: 'track', }) ) @@ -177,11 +178,11 @@ describe('Plugin flushing', () => { type: 'destination', } - await eq.register(CoreContext.system(), amplitude, ajs) - await eq.register(CoreContext.system(), fullstory, ajs) + await eq.register(TestCtx.system(), amplitude, ajs) + await eq.register(TestCtx.system(), fullstory, ajs) const flushed = await eq.dispatch( - new CoreContext({ + new TestCtx({ type: 'track', }) ) @@ -243,11 +244,11 @@ describe('Plugin flushing', () => { type: 'after', } - await eq.register(CoreContext.system(), afterFailed, ajs) - await eq.register(CoreContext.system(), after, ajs) + await eq.register(TestCtx.system(), afterFailed, ajs) + await eq.register(TestCtx.system(), after, ajs) const flushed = await eq.dispatch( - new CoreContext({ + new TestCtx({ type: 'track', }) ) @@ -294,7 +295,7 @@ describe('Plugin flushing', () => { const eq = new EventQueue() await eq.register( - CoreContext.system(), + TestCtx.system(), { ...testPlugin, name: 'Kiwi', @@ -308,7 +309,7 @@ describe('Plugin flushing', () => { ) await eq.register( - CoreContext.system(), + TestCtx.system(), { ...testPlugin, name: 'Watermelon', @@ -322,7 +323,7 @@ describe('Plugin flushing', () => { ) let trackCalled = false await eq.register( - CoreContext.system(), + TestCtx.system(), { ...testPlugin, name: 'Before', @@ -336,7 +337,7 @@ describe('Plugin flushing', () => { ) const flushed = await eq.dispatch( - new CoreContext({ + new TestCtx({ type: 'track', }) ) @@ -401,11 +402,11 @@ describe('Plugin flushing', () => { // shuffle plugins so we can verify order const plugins = shuffle([before, enrichment, enrichmentTwo, destination]) for (const xt of plugins) { - await eq.register(CoreContext.system(), xt, ajs) + await eq.register(TestCtx.system(), xt, ajs) } await eq.dispatch( - new CoreContext({ + new TestCtx({ type: 'track', }) ) diff --git a/packages/core/src/queue/delivery.ts b/packages/core/src/queue/delivery.ts index 31825a15a..79cc0e7d0 100644 --- a/packages/core/src/queue/delivery.ts +++ b/packages/core/src/queue/delivery.ts @@ -1,19 +1,18 @@ import { CoreContext, ContextCancelation } from '../context' import { CorePlugin } from '../plugins' -async function tryOperation( - op: () => CoreContext | Promise -): Promise { + +async function tryAsync(fn: () => T | Promise): Promise { try { - return await op() + return await fn() } catch (err) { return Promise.reject(err) } } -export function attempt( - ctx: CoreContext, - plugin: CorePlugin -): Promise { +export function attempt( + ctx: Ctx, + plugin: CorePlugin +): Promise { ctx.log('debug', 'plugin', { plugin: plugin.name }) const start = new Date().getTime() @@ -22,14 +21,14 @@ export function attempt( return Promise.resolve(ctx) } - const newCtx = tryOperation(() => hook.apply(plugin, [ctx])) + const newCtx = tryAsync(() => hook.apply(plugin, [ctx])) .then((ctx) => { const done = new Date().getTime() - start - ctx.stats?.gauge('plugin_time', done, [`plugin:${plugin.name}`]) + ctx.stats.gauge('plugin_time', done, [`plugin:${plugin.name}`]) return ctx }) - .catch((err) => { + .catch((err: Error | ContextCancelation) => { if ( err instanceof ContextCancelation && err.type === 'middleware_cancellation' @@ -50,25 +49,25 @@ export function attempt( plugin: plugin.name, error: err, }) - ctx.stats?.increment('plugin_error', 1, [`plugin:${plugin.name}`]) + ctx.stats.increment('plugin_error', 1, [`plugin:${plugin.name}`]) - return err as Error + return err }) return newCtx } -export function ensure( - ctx: CoreContext, - plugin: CorePlugin -): Promise { +export function ensure( + ctx: Ctx, + plugin: CorePlugin +): Promise { return attempt(ctx, plugin).then((newContext) => { if (newContext instanceof CoreContext) { return newContext } ctx.log('debug', 'Context canceled') - ctx.stats?.increment('context_canceled') + ctx.stats.increment('context_canceled') ctx.cancel(newContext) }) } diff --git a/packages/core/src/queue/event-queue.ts b/packages/core/src/queue/event-queue.ts index 1775d2080..75453f53e 100644 --- a/packages/core/src/queue/event-queue.ts +++ b/packages/core/src/queue/event-queue.ts @@ -4,32 +4,24 @@ import { ON_REMOVE_FROM_FUTURE, PriorityQueue } from '../priority-queue' import { CoreContext, ContextCancelation } from '../context' import { Emitter } from '../emitter' -import { Integrations } from '../events/interfaces' +import { Integrations, JSONObject } from '../events/interfaces' import { CorePlugin } from '../plugins' import { createTaskGroup, TaskGroup } from '../task/task-group' import { attempt, ensure } from './delivery' import { isOffline } from '../connection' -type CorePluginsByType = { - before: CorePlugin[] - after: CorePlugin[] - enrichment: CorePlugin[] - destinations: CorePlugin[] +export type EventQueueEmitterContract = { + message_delivered: [ctx: Ctx] + message_enriched: [ctx: Ctx] + delivery_success: [ctx: Ctx] + delivery_failure: [ctx: Ctx, err: Ctx | Error | ContextCancelation] + flush: [ctx: Ctx, delivered: boolean] } -export type EventQueueEmitterContract = { - message_dispatched: [ctx: CoreContext] - message_delivered: [ctx: CoreContext] - message_enriched: [ctx: CoreContext] - delivery_success: [ctx: CoreContext] - delivery_failure: [ - ctx: CoreContext, - err: CoreContext | Error | ContextCancelation - ] - flush: [ctx: CoreContext, delivered: boolean] -} - -export class EventQueue extends Emitter { +export abstract class CoreEventQueue< + Ctx extends CoreContext = CoreContext, + Plugin extends CorePlugin = CorePlugin +> extends Emitter> { /** * All event deliveries get suspended until all the tasks in this task group are complete. * For example: a middleware that augments the event object should be loaded safely as a @@ -38,12 +30,12 @@ export class EventQueue extends Emitter { * This applies to all the events already in the queue, and the upcoming ones */ criticalTasks: TaskGroup = createTaskGroup() - queue: PriorityQueue - plugins: CorePlugin[] = [] + queue: PriorityQueue + plugins: Plugin[] = [] failedInitializations: string[] = [] private flushing = false - constructor(priorityQueue: PriorityQueue) { + constructor(priorityQueue: PriorityQueue) { super() this.queue = priorityQueue @@ -53,8 +45,8 @@ export class EventQueue extends Emitter { } async register( - ctx: CoreContext, - plugin: CorePlugin, + ctx: Ctx, + plugin: Plugin, instance: CoreAnalytics ): Promise { await Promise.resolve(plugin.load(ctx, instance)) @@ -79,8 +71,8 @@ export class EventQueue extends Emitter { } async deregister( - ctx: CoreContext, - plugin: CorePlugin, + ctx: Ctx, + plugin: CorePlugin, instance: CoreAnalytics ): Promise { try { @@ -97,10 +89,9 @@ export class EventQueue extends Emitter { } } - async dispatch(ctx: CoreContext): Promise { + async dispatch(ctx: Ctx): Promise { ctx.log('debug', 'Dispatching') - this.emit('message_dispatched', ctx) - ctx.stats?.increment('message_dispatched') + ctx.stats.increment('message_dispatched') this.queue.push(ctx) const willDeliver = this.subscribeToDelivery(ctx) @@ -108,9 +99,9 @@ export class EventQueue extends Emitter { return willDeliver } - private async subscribeToDelivery(ctx: CoreContext): Promise { + private async subscribeToDelivery(ctx: Ctx): Promise { return new Promise((resolve) => { - const onDeliver = (flushed: CoreContext, delivered: boolean): void => { + const onDeliver = (flushed: Ctx, delivered: boolean): void => { if (flushed.isSame(ctx)) { this.off('flush', onDeliver) if (delivered) { @@ -125,9 +116,9 @@ export class EventQueue extends Emitter { }) } - async dispatchSingle(ctx: CoreContext): Promise { + async dispatchSingle(ctx: Ctx): Promise { ctx.log('debug', 'Dispatching') - this.emit('message_dispatched', ctx) + ctx.stats.increment('message_dispatched') this.queue.updateAttempts(ctx) ctx.attempts = 1 @@ -168,27 +159,27 @@ export class EventQueue extends Emitter { }, timeout) } - private async deliver(ctx: CoreContext): Promise { + private async deliver(ctx: Ctx): Promise { await this.criticalTasks.done() const start = Date.now() try { ctx = await this.flushOne(ctx) const done = Date.now() - start - this.emit('delivery_success', ctx) // TODO: normalize emitter - ctx.stats?.gauge('delivered', done) + this.emit('delivery_success', ctx) + ctx.stats.gauge('delivered', done) ctx.log('debug', 'Delivered', ctx.event) return ctx } catch (err: any) { - const error = err as CoreContext | Error | ContextCancelation + const error = err as Ctx | Error | ContextCancelation ctx.log('error', 'Failed to deliver', error) this.emit('delivery_failure', ctx, error) - ctx.stats?.increment('delivery_failed') + ctx.stats.increment('delivery_failed') throw err } } - private enqueuRetry(err: Error, ctx: CoreContext): boolean { + private enqueuRetry(err: Error, ctx: Ctx): boolean { const retriable = !(err instanceof ContextCancelation) || err.retry if (!retriable) { return false @@ -197,7 +188,7 @@ export class EventQueue extends Emitter { return this.queue.pushWithBackoff(ctx) } - async flush(): Promise { + async flush(): Promise { if (this.queue.length === 0 || isOffline()) { return [] } @@ -211,7 +202,7 @@ export class EventQueue extends Emitter { try { ctx = await this.deliver(ctx) - this.emit('flush', ctx, true) // TODO: normalize emitter + this.emit('flush', ctx, true) } catch (err: any) { const accepted = this.enqueuRetry(err, ctx) @@ -232,16 +223,24 @@ export class EventQueue extends Emitter { return true } - private availableExtensions(denyList: Integrations): CorePluginsByType { + private availableExtensions(denyList: Integrations) { const available = this.plugins.filter((p) => { // Only filter out destination plugins or the Segment.io plugin if (p.type !== 'destination' && p.name !== 'Segment.io') { return true } + let alternativeNameMatch: boolean | JSONObject | undefined = undefined + p.alternativeNames?.forEach((name) => { + if (denyList[name] !== undefined) { + alternativeNameMatch = denyList[name] + } + }) + // Explicit integration option takes precedence, `All: false` does not apply to Segment.io return ( denyList[p.name] ?? + alternativeNameMatch ?? (p.name === 'Segment.io' ? true : denyList.All) !== false ) }) @@ -261,7 +260,7 @@ export class EventQueue extends Emitter { } } - private async flushOne(ctx: CoreContext): Promise { + private async flushOne(ctx: Ctx): Promise { if (!this.isReady()) { throw new Error('Not ready') } @@ -271,7 +270,7 @@ export class EventQueue extends Emitter { ) for (const beforeWare of before) { - const temp: CoreContext | undefined = await ensure(ctx, beforeWare) + const temp = await ensure(ctx, beforeWare) if (temp instanceof CoreContext) { ctx = temp } @@ -301,7 +300,7 @@ export class EventQueue extends Emitter { }, 0) }) - ctx.stats?.increment('message_delivered') + ctx.stats.increment('message_delivered') this.emit('message_delivered', ctx) diff --git a/packages/core/src/stats/__tests__/index.test.ts b/packages/core/src/stats/__tests__/index.test.ts new file mode 100644 index 000000000..28c38a7bd --- /dev/null +++ b/packages/core/src/stats/__tests__/index.test.ts @@ -0,0 +1,103 @@ +import { CoreStats } from '..' + +class Stats extends CoreStats {} + +describe(CoreStats, () => { + test('starts out empty', () => { + const stats = new Stats() + expect(stats.metrics).toEqual([]) + }) + + test('records increments', () => { + const stats = new Stats() + + stats.increment('m1') + stats.increment('m2', 2) + stats.increment('m3', 3, ['test:env']) + + expect(stats.metrics).toEqual([ + { + metric: 'm1', + tags: [], + timestamp: expect.any(Number), + type: 'counter', + value: 1, + }, + { + metric: 'm2', + tags: [], + timestamp: expect.any(Number), + type: 'counter', + value: 2, + }, + { + metric: 'm3', + tags: ['test:env'], + timestamp: expect.any(Number), + type: 'counter', + value: 3, + }, + ]) + }) + + test('records gauges', () => { + const stats = new Stats() + + stats.gauge('m1', 1) + stats.gauge('m2', 2, ['test:env']) + + expect(stats.metrics).toEqual([ + { + metric: 'm1', + tags: [], + timestamp: expect.any(Number), + type: 'gauge', + value: 1, + }, + { + metric: 'm2', + tags: ['test:env'], + timestamp: expect.any(Number), + type: 'gauge', + value: 2, + }, + ]) + }) + + test('serializes metrics to a more compact format', () => { + const stats = new Stats() + + stats.gauge('some_gauge', 31, ['test:env']) + stats.increment('some_increment', 22, ['test:env']) + + expect(stats.serialize()).toEqual([ + { + e: expect.any(Number), + k: 'g', + m: 'some_gauge', + t: ['test:env'], + v: 31, + }, + { + e: expect.any(Number), + k: 'c', + m: 'some_increment', + t: ['test:env'], + v: 22, + }, + ]) + }) + + test('flushes metrics', () => { + jest.spyOn(console, 'table').mockImplementationOnce(() => {}) + + const stats = new Stats() + stats.gauge('some_gauge', 31, ['test:env']) + stats.increment('some_increment', 22, ['test:env']) + + stats.flush() + + expect(stats.metrics).toEqual([]) + expect(console.table).toHaveBeenCalled() + }) +}) diff --git a/packages/core/src/stats/index.ts b/packages/core/src/stats/index.ts index 6c6d22fb1..4bcfd0bab 100644 --- a/packages/core/src/stats/index.ts +++ b/packages/core/src/stats/index.ts @@ -1,12 +1,11 @@ -import { RemoteMetrics } from './remote-metrics' - -type MetricType = 'gauge' | 'counter' type CompactMetricType = 'g' | 'c' -export interface Metric { +export type CoreMetricType = 'gauge' | 'counter' + +export interface CoreMetric { metric: string value: number - type: MetricType + type: CoreMetricType tags: string[] timestamp: number // unit milliseconds } @@ -19,23 +18,16 @@ export interface CompactMetric { e: number // timestamp in unit milliseconds } -const compactMetricType = (type: MetricType): CompactMetricType => { - const enums: Record = { +const compactMetricType = (type: CoreMetricType): CompactMetricType => { + const enums: Record = { gauge: 'g', counter: 'c', } return enums[type] } -export default class Stats { - metrics: Metric[] = [] - - private remoteMetrics?: RemoteMetrics - - constructor(remoteMetrics?: RemoteMetrics) { - this.remoteMetrics = remoteMetrics - } - +export abstract class CoreStats { + metrics: CoreMetric[] = [] increment(metric: string, by = 1, tags?: string[]): void { this.metrics.push({ metric, @@ -44,8 +36,6 @@ export default class Stats { type: 'counter', timestamp: Date.now(), }) - - void this.remoteMetrics?.increment(metric, tags ?? []) } gauge(metric: string, value: number, tags?: string[]): void { @@ -87,3 +77,12 @@ export default class Stats { }) } } + +export class NullStats extends CoreStats { + override gauge(..._args: Parameters) {} + override increment(..._args: Parameters) {} + override flush(..._args: Parameters) {} + override serialize(..._args: Parameters) { + return [] + } +} diff --git a/packages/core/src/stats/remote-metrics.ts b/packages/core/src/stats/remote-metrics.ts deleted file mode 100644 index 41f0e7cce..000000000 --- a/packages/core/src/stats/remote-metrics.ts +++ /dev/null @@ -1,16 +0,0 @@ -type MetricType = 'gauge' | 'counter' - -export interface Metric { - metric: string - value: number - type: MetricType - tags: string[] - timestamp: number // unit milliseconds -} - -export interface RemoteMetrics { - sampleRate: number - increment(metric: string, tags: string[]): Promise - queue: Metric[] - flush: Promise -} diff --git a/packages/core/test-helpers/index.ts b/packages/core/test-helpers/index.ts new file mode 100644 index 000000000..504b37f1b --- /dev/null +++ b/packages/core/test-helpers/index.ts @@ -0,0 +1,2 @@ +export * from './test-ctx' +export * from './test-event-queue' diff --git a/packages/core/test-helpers/test-ctx.ts b/packages/core/test-helpers/test-ctx.ts new file mode 100644 index 000000000..74265fe3e --- /dev/null +++ b/packages/core/test-helpers/test-ctx.ts @@ -0,0 +1,7 @@ +import { CoreContext } from '../src/context' + +export class TestCtx extends CoreContext { + static override system() { + return new this({ type: 'track', event: 'system' }) + } +} diff --git a/packages/core/test-helpers/test-event-queue.ts b/packages/core/test-helpers/test-event-queue.ts new file mode 100644 index 000000000..2ab338a1d --- /dev/null +++ b/packages/core/test-helpers/test-event-queue.ts @@ -0,0 +1,7 @@ +import { CoreEventQueue, PriorityQueue } from '../src' + +export class TestEventQueue extends CoreEventQueue { + constructor() { + super(new PriorityQueue(4, [])) + } +} diff --git a/packages/node-integration-tests/package.json b/packages/node-integration-tests/package.json index 523b5c8c4..8c3ea8b5f 100644 --- a/packages/node-integration-tests/package.json +++ b/packages/node-integration-tests/package.json @@ -16,10 +16,8 @@ "@segment/analytics-node": "workspace:^", "@types/analytics-node": "^3.1.9", "@types/autocannon": "^7", - "@types/express": "^4", "analytics-node": "^6.2.0", "autocannon": "^7.10.0", - "express": "^4.17.3", "nock": "^13.2.9" }, "packageManager": "yarn@3.2.1" diff --git a/packages/node/package.json b/packages/node/package.json index 61c71fb22..df8de345f 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -24,7 +24,7 @@ "build": "yarn concurrently 'yarn:build:*'", "build:cjs": "yarn tsc -p tsconfig.build.json --outDir ./dist/cjs --module commonjs", "build:esm": "yarn tsc -p tsconfig.build.json", - "watch": "yarn build:esm --watch --incremental", + "watch": "yarn build:esm --watch", "watch:test": "yarn test --watch", "tsc": "yarn run -T tsc", "eslint": "yarn run -T eslint", diff --git a/packages/node/src/__tests__/callback.test.ts b/packages/node/src/__tests__/callback.test.ts index 3ab61c7cd..426171ec8 100644 --- a/packages/node/src/__tests__/callback.test.ts +++ b/packages/node/src/__tests__/callback.test.ts @@ -3,7 +3,7 @@ jest.mock('../lib/fetch', () => ({ fetch: fetcher })) import { createError, createSuccess } from './test-helpers/factories' import { createTestAnalytics } from './test-helpers/create-test-analytics' -import { Context } from '../app/analytics-node' +import { Context } from '../app/context' describe('Callback behavior', () => { beforeEach(() => { diff --git a/packages/node/src/__tests__/graceful-shutdown-integration.test.ts b/packages/node/src/__tests__/graceful-shutdown-integration.test.ts index f98117ab7..bd942ab3a 100644 --- a/packages/node/src/__tests__/graceful-shutdown-integration.test.ts +++ b/packages/node/src/__tests__/graceful-shutdown-integration.test.ts @@ -6,10 +6,10 @@ jest.mock('../lib/fetch', () => ({ fetch: fetcher })) import { Analytics } from '../app/analytics-node' import { sleep } from './test-helpers/sleep' -import { CoreContext, CorePlugin } from '@segment/analytics-core' -import { SegmentEvent } from '../app/types' +import { Plugin, SegmentEvent } from '../app/types' +import { Context } from '../app/context' -const testPlugin: CorePlugin = { +const testPlugin: Plugin = { type: 'after', load: () => Promise.resolve(), name: 'foo', @@ -68,7 +68,7 @@ describe('Ability for users to exit without losing events', () => { }) test('all async callbacks should be called', async () => { - const trackCall = new Promise((resolve) => { + const trackCall = new Promise((resolve) => { ajs.track( { userId: 'abc', @@ -79,7 +79,7 @@ describe('Ability for users to exit without losing events', () => { }) const res = await Promise.race([ajs.closeAndFlush(), trackCall]) - expect(res instanceof CoreContext).toBe(true) + expect(res instanceof Context).toBe(true) }) }) @@ -209,7 +209,7 @@ describe('Ability for users to exit without losing events', () => { test('should wait to flush if close is called and an event has not made it to the segment.io plugin yet', async () => { const TRACK_DELAY = 100 - const _testPlugin: CorePlugin = { + const _testPlugin: Plugin = { ...testPlugin, track: async (ctx) => { await sleep(TRACK_DELAY) diff --git a/packages/node/src/__tests__/integration.test.ts b/packages/node/src/__tests__/integration.test.ts index 8f7f44cef..4c2bcb156 100644 --- a/packages/node/src/__tests__/integration.test.ts +++ b/packages/node/src/__tests__/integration.test.ts @@ -1,7 +1,7 @@ const fetcher = jest.fn() jest.mock('../lib/fetch', () => ({ fetch: fetcher })) -import { CorePlugin as Plugin } from '@segment/analytics-core' +import { Plugin } from '../app/types' import { resolveCtx } from './test-helpers/resolve-ctx' import { testPlugin } from './test-helpers/test-plugin' import { createSuccess, createError } from './test-helpers/factories' diff --git a/packages/node/src/__tests__/test-helpers/resolve-ctx.ts b/packages/node/src/__tests__/test-helpers/resolve-ctx.ts index a2497fca8..dbe730170 100644 --- a/packages/node/src/__tests__/test-helpers/resolve-ctx.ts +++ b/packages/node/src/__tests__/test-helpers/resolve-ctx.ts @@ -1,4 +1,5 @@ -import { Analytics, Context } from '../../app/analytics-node' +import type { Analytics } from '../../app/analytics-node' +import type { Context } from '../../app/context' /** Tester helper that resolves context from emitter event */ export const resolveCtx = ( diff --git a/packages/node/src/__tests__/test-helpers/test-plugin.ts b/packages/node/src/__tests__/test-helpers/test-plugin.ts index 394e3bf96..088fbfb98 100644 --- a/packages/node/src/__tests__/test-helpers/test-plugin.ts +++ b/packages/node/src/__tests__/test-helpers/test-plugin.ts @@ -1,6 +1,6 @@ -import { CorePlugin } from '@segment/analytics-core' +import { Plugin } from '../../app/types' -export const testPlugin: CorePlugin = { +export const testPlugin: Plugin = { isLoaded: jest.fn().mockReturnValue(true), load: jest.fn().mockResolvedValue(undefined), unload: jest.fn().mockResolvedValue(undefined), diff --git a/packages/node/src/app/analytics-node.ts b/packages/node/src/app/analytics-node.ts index 7d00fff63..05fec0662 100644 --- a/packages/node/src/app/analytics-node.ts +++ b/packages/node/src/app/analytics-node.ts @@ -1,12 +1,4 @@ -import { - CoreAnalytics, - EventFactory, - EventQueue, - CoreSegmentEvent, - bindAll, - pTimeout, - CoreContext, -} from '@segment/analytics-core' +import { CoreAnalytics, bindAll, pTimeout } from '@segment/analytics-core' import { AnalyticsSettings, validateSettings } from './settings' import { version } from '../../package.json' import { createConfiguredNodePlugin } from '../plugins/segmentio' @@ -22,14 +14,11 @@ import { Plugin, SegmentEvent, } from './types' +import { Context } from './context' import { NodeEventQueue } from './event-queue' -// create a derived class since we may want to add node specific things to Context later -// While this is not a type, it is a definition -export class Context extends CoreContext {} - export class Analytics extends NodeEmitter implements CoreAnalytics { - private readonly _eventFactory: EventFactory + private readonly _eventFactory: NodeEventFactory private _isClosed = false private _pendingEvents = 0 private readonly _closeAndFlushDefaultTimeout: number @@ -37,7 +26,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { typeof createConfiguredNodePlugin >['publisher'] - private readonly _queue: EventQueue + private readonly _queue: NodeEventQueue ready: Promise @@ -96,7 +85,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { return timeout ? pTimeout(promise, timeout).catch(() => undefined) : promise } - private _dispatch(segmentEvent: CoreSegmentEvent, callback?: Callback) { + private _dispatch(segmentEvent: SegmentEvent, callback?: Callback) { if (this._isClosed) { this.emit('call_after_close', segmentEvent as SegmentEvent) return undefined @@ -261,7 +250,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { * Registers one or more plugins to augment Analytics functionality. * @param plugins */ - async register(...plugins: Plugin[]): Promise { + register(...plugins: Plugin[]): Promise { return this._queue.criticalTasks.run(async () => { const ctx = Context.system() @@ -283,7 +272,7 @@ export class Analytics extends NodeEmitter implements CoreAnalytics { async deregister(...pluginNames: string[]): Promise { const ctx = Context.system() - const deregistrations = pluginNames.map(async (pl) => { + const deregistrations = pluginNames.map((pl) => { const plugin = this._queue.plugins.find((p) => p.name === pl) if (plugin) { return this._queue.deregister(ctx, plugin, this) diff --git a/packages/node/src/app/context.ts b/packages/node/src/app/context.ts new file mode 100644 index 000000000..46d5b4596 --- /dev/null +++ b/packages/node/src/app/context.ts @@ -0,0 +1,11 @@ +// create a derived class since we may want to add node specific things to Context later + +import { CoreContext } from '@segment/analytics-core' +import { SegmentEvent } from './types' + +// While this is not a type, it is a definition +export class Context extends CoreContext { + static override system() { + return new this({ type: 'track', event: 'system' }) + } +} diff --git a/packages/node/src/app/dispatch-emit.ts b/packages/node/src/app/dispatch-emit.ts index 45665f239..35f0f19e3 100644 --- a/packages/node/src/app/dispatch-emit.ts +++ b/packages/node/src/app/dispatch-emit.ts @@ -1,27 +1,26 @@ import { dispatch } from '@segment/analytics-core' -import type { - CoreContext, - CoreSegmentEvent, - EventQueue, -} from '@segment/analytics-core' import type { NodeEmitter } from './emitter' +import { Context } from './context' +import { NodeEventQueue } from './event-queue' +import { SegmentEvent } from './types' -export type Callback = (err?: unknown, ctx?: CoreContext) => void +export type Callback = (err?: unknown, ctx?: Context) => void -const normalizeDispatchCb = (cb: Callback) => (ctx: CoreContext) => { +const normalizeDispatchCb = (cb: Callback) => (ctx: Context) => { const failedDelivery = ctx.failedDelivery() return failedDelivery ? cb(failedDelivery.reason, ctx) : cb(undefined, ctx) } /* Dispatch function, but swallow promise rejections and use event emitter instead */ export const dispatchAndEmit = async ( - event: CoreSegmentEvent, - queue: EventQueue, + event: SegmentEvent, + queue: NodeEventQueue, emitter: NodeEmitter, callback?: Callback ): Promise => { try { - const ctx = await dispatch(event, queue, emitter, { + const context = new Context(event) + const ctx = await dispatch(context, queue, emitter, { ...(callback ? { callback: normalizeDispatchCb(callback) } : {}), }) const failedDelivery = ctx.failedDelivery() diff --git a/packages/node/src/app/emitter.ts b/packages/node/src/app/emitter.ts index 496c9dac7..ad32d03e2 100644 --- a/packages/node/src/app/emitter.ts +++ b/packages/node/src/app/emitter.ts @@ -1,5 +1,5 @@ import { CoreEmitterContract, Emitter } from '@segment/analytics-core' -import { Context } from './analytics-node' +import { Context } from './context' import type { AnalyticsSettings } from './settings' import { SegmentEvent } from './types' diff --git a/packages/node/src/app/event-factory.ts b/packages/node/src/app/event-factory.ts index 83e67a25d..606adb64a 100644 --- a/packages/node/src/app/event-factory.ts +++ b/packages/node/src/app/event-factory.ts @@ -1,5 +1,17 @@ import { EventFactory } from '@segment/analytics-core' import { createMessageId } from '../lib/get-message-id' +import { SegmentEvent } from './types' + +// use declaration merging to downcast CoreSegmentEvent without adding any runtime code. +// if/when we decide to add an actual implementation to NodeEventFactory that actually changes the event shape, we can remove this. +export interface NodeEventFactory { + alias(...args: Parameters): SegmentEvent + group(...args: Parameters): SegmentEvent + identify(...args: Parameters): SegmentEvent + track(...args: Parameters): SegmentEvent + page(...args: Parameters): SegmentEvent + screen(...args: Parameters): SegmentEvent +} export class NodeEventFactory extends EventFactory { constructor() { diff --git a/packages/node/src/app/event-queue.ts b/packages/node/src/app/event-queue.ts index 8a7d90e5a..835b78acb 100644 --- a/packages/node/src/app/event-queue.ts +++ b/packages/node/src/app/event-queue.ts @@ -1,5 +1,6 @@ -import { EventQueue, PriorityQueue } from '@segment/analytics-core' -import type { Context } from './analytics-node' +import { CoreEventQueue, PriorityQueue } from '@segment/analytics-core' +import type { Plugin } from '../app/types' +import type { Context } from './context' class NodePriorityQueue extends PriorityQueue { constructor() { @@ -15,7 +16,7 @@ class NodePriorityQueue extends PriorityQueue { } } -export class NodeEventQueue extends EventQueue { +export class NodeEventQueue extends CoreEventQueue { constructor() { super(new NodePriorityQueue()) } diff --git a/packages/node/src/app/types/plugin.ts b/packages/node/src/app/types/plugin.ts index 672c38596..a999691e6 100644 --- a/packages/node/src/app/types/plugin.ts +++ b/packages/node/src/app/types/plugin.ts @@ -1,3 +1,5 @@ import type { CorePlugin } from '@segment/analytics-core' +import type { Analytics } from '../analytics-node' +import type { Context } from '../context' -export interface Plugin extends CorePlugin {} +export interface Plugin extends CorePlugin {} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index de019df23..ba1612ddc 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -1,4 +1,5 @@ -export { Analytics, Context } from './app/analytics-node' +export { Analytics } from './app/analytics-node' +export { Context } from './app/context' export { ExtraContext, Plugin, Traits } from './app/types' export type { AnalyticsSettings } from './app/settings' diff --git a/packages/node/src/plugins/segmentio/__tests__/index.test.ts b/packages/node/src/plugins/segmentio/__tests__/index.test.ts index da58988f4..dbefbd96a 100644 --- a/packages/node/src/plugins/segmentio/__tests__/index.test.ts +++ b/packages/node/src/plugins/segmentio/__tests__/index.test.ts @@ -1,7 +1,6 @@ const fetcher = jest.fn() jest.mock('../../../lib/fetch', () => ({ fetch: fetcher })) import { range } from 'lodash' -import { CoreContext } from '@segment/analytics-core' import { NodeEventFactory } from '../../../app/event-factory' import { createError, @@ -9,6 +8,7 @@ import { } from '../../../__tests__/test-helpers/factories' import { createConfiguredNodePlugin } from '../index' import { PublisherProps } from '../publisher' +import { Context } from '../../../app/context' const createTestNodePlugin = (props: PublisherProps) => createConfiguredNodePlugin(props).plugin @@ -28,7 +28,7 @@ const bodyPropertyMatchers = { integrations: {}, } -function validateFetcherInputs(...contexts: CoreContext[]) { +function validateFetcherInputs(...contexts: Context[]) { const [url, request] = fetcher.mock.lastCall const body = JSON.parse(request.body) @@ -69,7 +69,7 @@ describe('SegmentNodePlugin', () => { }) const event = eventFactory.alias('to', 'from') - const context = new CoreContext(event) + const context = new Context(event) fetcher.mockReturnValueOnce(createSuccess()) await segmentPlugin.alias(context) @@ -104,7 +104,7 @@ describe('SegmentNodePlugin', () => { }, { userId: 'foo-user-id' } ) - const context = new CoreContext(event) + const context = new Context(event) fetcher.mockReturnValueOnce(createSuccess()) await segmentPlugin.group(context) @@ -138,7 +138,7 @@ describe('SegmentNodePlugin', () => { const event = eventFactory.identify('foo-user-id', { name: 'Chris Radek', }) - const context = new CoreContext(event) + const context = new Context(event) fetcher.mockReturnValueOnce(createSuccess()) await segmentPlugin.identify(context) @@ -173,7 +173,7 @@ describe('SegmentNodePlugin', () => { { url: 'http://localhost' }, { userId: 'foo-user-id' } ) - const context = new CoreContext(event) + const context = new Context(event) fetcher.mockReturnValueOnce(createSuccess()) await segmentPlugin.page(context) @@ -212,7 +212,7 @@ describe('SegmentNodePlugin', () => { { variation: 'local' }, { userId: 'foo-user-id' } ) - const context = new CoreContext(event) + const context = new Context(event) fetcher.mockReturnValueOnce(createSuccess()) await segmentPlugin.screen(context) @@ -249,7 +249,7 @@ describe('SegmentNodePlugin', () => { { foo: 'bar' }, { userId: 'foo-user-id' } ) - const context = new CoreContext(event) + const context = new Context(event) fetcher.mockReturnValueOnce(createSuccess()) await segmentPlugin.screen(context) @@ -293,7 +293,7 @@ describe('SegmentNodePlugin', () => { eventFactory.identify('foo-user-id', { name: 'Chris Radek', }), - ].map((event) => new CoreContext(event)) + ].map((event) => new Context(event)) for (const context of contexts) { // We want batching to happen, so don't await. @@ -314,7 +314,7 @@ describe('SegmentNodePlugin', () => { writeKey: '', }) - const context = new CoreContext(eventFactory.alias('to', 'from')) + const context = new Context(eventFactory.alias('to', 'from')) const pendingContext = segmentPlugin.alias(context) @@ -342,12 +342,12 @@ describe('SegmentNodePlugin', () => { writeKey: '', }) - const context = new CoreContext(eventFactory.alias('to', 'from')) + const context = new Context(eventFactory.alias('to', 'from')) - const contexts: CoreContext[] = [] + const contexts: Context[] = [] // Fill up 1 batch and partially fill another for (let i = 0; i < 3; i++) { - contexts.push(new CoreContext(eventFactory.alias('to', 'from'))) + contexts.push(new Context(eventFactory.alias('to', 'from'))) } const pendingContexts = contexts.map((ctx) => segmentPlugin.alias(ctx)) @@ -378,11 +378,11 @@ describe('SegmentNodePlugin', () => { writeKey: '', }) - const contexts: CoreContext[] = [] + const contexts: Context[] = [] // Max batch size is ~480KB, so adding 16 events with 30KB buffers will hit the limit. for (let i = 0; i < 16; i++) { contexts.push( - new CoreContext( + new Context( eventFactory.track( 'Test Event', { @@ -409,7 +409,7 @@ describe('SegmentNodePlugin', () => { describe('flushAfterClose', () => { const _createTrackCtx = () => - new CoreContext( + new Context( eventFactory.track( 'test event', { foo: 'bar' }, @@ -419,7 +419,7 @@ describe('SegmentNodePlugin', () => { it('sends immediately once all pending events reach the segment plugin, regardless of settings like batch size', async () => { const _createTrackCtx = () => - new CoreContext( + new Context( eventFactory.track( 'test event', { foo: 'bar' }, @@ -531,7 +531,7 @@ describe('SegmentNodePlugin', () => { writeKey: '', }) - const context = new CoreContext( + const context = new Context( eventFactory.track( 'Test Event', { @@ -567,7 +567,7 @@ describe('SegmentNodePlugin', () => { writeKey: '', }) - const context = new CoreContext(eventFactory.alias('to', 'from')) + const context = new Context(eventFactory.alias('to', 'from')) const updatedContext = await segmentPlugin.alias(context) @@ -598,7 +598,7 @@ describe('SegmentNodePlugin', () => { writeKey: '', }) - const context = new CoreContext(eventFactory.alias('to', 'from')) + const context = new Context(eventFactory.alias('to', 'from')) const pendingContext = segmentPlugin.alias(context) const updatedContext = await pendingContext @@ -628,7 +628,7 @@ describe('SegmentNodePlugin', () => { writeKey: '', }) - const context = new CoreContext(eventFactory.alias('my', 'from')) + const context = new Context(eventFactory.alias('my', 'from')) const pendingContext = segmentPlugin.alias(context) const updatedContext = await pendingContext diff --git a/packages/node/src/plugins/segmentio/context-batch.ts b/packages/node/src/plugins/segmentio/context-batch.ts index f1e7519ff..4e6086d31 100644 --- a/packages/node/src/plugins/segmentio/context-batch.ts +++ b/packages/node/src/plugins/segmentio/context-batch.ts @@ -1,12 +1,13 @@ import { v4 as uuid } from '@lukeed/uuid' -import { CoreContext, CoreSegmentEvent } from '@segment/analytics-core' +import type { Context } from '../../app/context' +import { SegmentEvent } from '../../app/types' const MAX_EVENT_SIZE_IN_KB = 32 const MAX_BATCH_SIZE_IN_KB = 480 // (500 KB is the limit, leaving some padding) interface PendingItem { - resolver: (ctx: CoreContext) => void - context: CoreContext + resolver: (ctx: Context) => void + context: Context } export class ContextBatch { @@ -51,16 +52,16 @@ export class ContextBatch { return this.items.length } - private calculateSize(ctx: CoreContext): number { + private calculateSize(ctx: Context): number { return encodeURI(JSON.stringify(ctx.event)).split(/%..|i/).length } - getEvents(): CoreSegmentEvent[] { + getEvents(): SegmentEvent[] { const events = this.items.map(({ context }) => context.event) return events } - getContexts(): CoreContext[] { + getContexts(): Context[] { return this.items.map((item) => item.context) } diff --git a/packages/node/src/plugins/segmentio/index.ts b/packages/node/src/plugins/segmentio/index.ts index 5f816ced5..c24817012 100644 --- a/packages/node/src/plugins/segmentio/index.ts +++ b/packages/node/src/plugins/segmentio/index.ts @@ -1,9 +1,10 @@ -import { CoreContext, CorePlugin } from '@segment/analytics-core' import { Publisher, PublisherProps } from './publisher' import { version } from '../../../package.json' import { detectRuntime } from '../../lib/env' +import { Plugin } from '../../app/types' +import { Context } from '../../app/context' -function normalizeEvent(ctx: CoreContext) { +function normalizeEvent(ctx: Context) { ctx.updateEvent('context.library.name', 'AnalyticsNode') ctx.updateEvent('context.library.version', version) const runtime = detectRuntime() @@ -26,13 +27,12 @@ type DefinedPluginFields = | 'screen' | 'track' -type SegmentNodePlugin = CorePlugin & - Required> +type SegmentNodePlugin = Plugin & Required> export type ConfigureNodePluginProps = PublisherProps export function createNodePlugin(publisher: Publisher): SegmentNodePlugin { - function action(ctx: CoreContext): Promise { + function action(ctx: Context): Promise { normalizeEvent(ctx) return publisher.enqueue(ctx) } diff --git a/packages/node/src/plugins/segmentio/publisher.ts b/packages/node/src/plugins/segmentio/publisher.ts index 876a6e0c3..afc9b639f 100644 --- a/packages/node/src/plugins/segmentio/publisher.ts +++ b/packages/node/src/plugins/segmentio/publisher.ts @@ -1,5 +1,6 @@ -import { backoff, CoreContext } from '@segment/analytics-core' +import { backoff } from '@segment/analytics-core' import { abortSignalAfterTimeout } from '../../lib/abort' +import type { Context } from '../../app/context' import { tryCreateFormattedUrl } from '../../lib/create-url' import { extractPromiseParts } from '../../lib/extract-promise-parts' import { fetch } from '../../lib/fetch' @@ -12,8 +13,8 @@ function sleep(timeoutInMs: number): Promise { function noop() {} interface PendingItem { - resolver: (ctx: CoreContext) => void - context: CoreContext + resolver: (ctx: Context) => void + context: Context } export interface PublisherProps { @@ -107,10 +108,10 @@ export class Publisher { * @param ctx - Context containing a Segment event. * @returns a promise that resolves with the context after the event has been delivered. */ - enqueue(ctx: CoreContext): Promise { + enqueue(ctx: Context): Promise { const batch = this._batch ?? this.createBatch() - const { promise: ctxPromise, resolve } = extractPromiseParts() + const { promise: ctxPromise, resolve } = extractPromiseParts() const pendingItem: PendingItem = { context: ctx, diff --git a/yarn.lock b/yarn.lock index 1de63ec0f..fceb39f78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1103,7 +1103,6 @@ __metadata: resolution: "@example/standalone-playground@workspace:examples/standalone-playground" dependencies: "@segment/analytics-next": "workspace:^" - concurrently: ^7.2.1 languageName: unknown linkType: soft @@ -1221,10 +1220,8 @@ __metadata: "@segment/analytics-node": "workspace:^" "@types/analytics-node": ^3.1.9 "@types/autocannon": ^7 - "@types/express": ^4 analytics-node: ^6.2.0 autocannon: ^7.10.0 - express: ^4.17.3 nock: ^13.2.9 languageName: unknown linkType: soft @@ -1940,7 +1937,7 @@ __metadata: languageName: node linkType: hard -"@segment/analytics-core@1.1.6, @segment/analytics-core@workspace:^, @segment/analytics-core@workspace:packages/core": +"@segment/analytics-core@workspace:*, @segment/analytics-core@workspace:packages/core": version: 0.0.0-use.local resolution: "@segment/analytics-core@workspace:packages/core" dependencies: @@ -1979,7 +1976,6 @@ __metadata: compression-webpack-plugin: ^8.0.1 dset: ^3.1.2 execa: ^4.1.0 - express: ^4.17.3 flat: ^5.0.2 fs-extra: ^9.0.1 jest-dev-server: ^6.0.3 @@ -3542,7 +3538,7 @@ __metadata: languageName: node linkType: hard -"@types/express@npm:^4": +"@types/express@npm:4": version: 4.17.14 resolution: "@types/express@npm:4.17.14" dependencies: @@ -4632,15 +4628,17 @@ __metadata: "@changesets/cli": ^2.23.2 "@internal/config": "workspace:^" "@npmcli/promise-spawn": ^3.0.0 + "@types/express": 4 "@types/jest": ^28.1.1 "@types/lodash": ^4 "@types/node-fetch": ^2.6.2 "@typescript-eslint/eslint-plugin": ^5.21.0 "@typescript-eslint/parser": ^5.21.0 - concurrently: ^7.2.1 + concurrently: ^7.6.0 eslint: ^8.14.0 eslint-config-prettier: ^8.5.0 eslint-plugin-prettier: ^4.0.0 + express: ^4.18.2 get-monorepo-packages: ^1.2.0 husky: ^8.0.0 jest: ^28.1.0 @@ -5901,22 +5899,23 @@ __metadata: languageName: node linkType: hard -"concurrently@npm:^7.2.1": - version: 7.2.1 - resolution: "concurrently@npm:7.2.1" +"concurrently@npm:^7.6.0": + version: 7.6.0 + resolution: "concurrently@npm:7.6.0" dependencies: chalk: ^4.1.0 - date-fns: ^2.16.1 + date-fns: ^2.29.1 lodash: ^4.17.21 - rxjs: ^6.6.3 + rxjs: ^7.0.0 shell-quote: ^1.7.3 spawn-command: ^0.0.2-1 supports-color: ^8.1.0 tree-kill: ^1.2.2 yargs: ^17.3.1 bin: + conc: dist/bin/concurrently.js concurrently: dist/bin/concurrently.js - checksum: 384e9f48f2c53a7c46b1bb1143b9dba734953c587e0c080db0c6e71dd4a58f556d096fe4fd9d2d9c601ba7ae8e6c06d452c162bbc2b6a4b16c080c1c4b8e81b4 + checksum: f705c9a7960f1b16559ca64958043faeeef6385c0bf30a03d1375e15ab2d96dba4f8166f1bbbb1c85e8da35ca0ce3c353875d71dff2aa132b2357bb533b3332e languageName: node linkType: hard @@ -6199,10 +6198,10 @@ __metadata: languageName: node linkType: hard -"date-fns@npm:^2.16.1": - version: 2.28.0 - resolution: "date-fns@npm:2.28.0" - checksum: a0516b2e4f99b8bffc6cc5193349f185f195398385bdcaf07f17c2c4a24473c99d933eb0018be4142a86a6d46cb0b06be6440ad874f15e795acbedd6fd727a1f +"date-fns@npm:^2.29.1": + version: 2.29.3 + resolution: "date-fns@npm:2.29.3" + checksum: e01cf5b62af04e05dfff921bb9c9933310ed0e1ae9a81eb8653452e64dc841acf7f6e01e1a5ae5644d0337e9a7f936175fd2cb6819dc122fdd9c5e86c56be484 languageName: node linkType: hard @@ -7433,7 +7432,7 @@ __metadata: languageName: node linkType: hard -"express@npm:^4.17.3": +"express@npm:^4.18.2": version: 4.18.2 resolution: "express@npm:4.18.2" dependencies: @@ -10458,13 +10457,6 @@ __metadata: languageName: node linkType: hard -"mime-db@npm:1.48.0": - version: 1.48.0 - resolution: "mime-db@npm:1.48.0" - checksum: d778392e474a5e78c24eef5a2894261f0ed168d2762c1ac2a115aa34c2274c9426178b92a6cc55e9edb8f13e7e9b8116380b0e61db9ff6d763e62876a65eea57 - languageName: node - linkType: hard - "mime-db@npm:1.52.0": version: 1.52.0 resolution: "mime-db@npm:1.52.0" @@ -10506,16 +10498,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:~2.1.24": - version: 2.1.31 - resolution: "mime-types@npm:2.1.31" - dependencies: - mime-db: 1.48.0 - checksum: eb1612aa96403823c7a2ccb1a39d58ce11477e685560186e7d369d8164260fd6fc1eeb56fa23acb6a4050583f417b2a685b69c23eb2bd8ed169fb0c6e323740a - languageName: node - linkType: hard - -"mime-types@npm:~2.1.34": +"mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -12442,12 +12425,12 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:^6.6.3": - version: 6.6.7 - resolution: "rxjs@npm:6.6.7" +"rxjs@npm:^7.0.0": + version: 7.6.0 + resolution: "rxjs@npm:7.6.0" dependencies: - tslib: ^1.9.0 - checksum: bc334edef1bb8bbf56590b0b25734ba0deaf8825b703256a93714308ea36dff8a11d25533671adf8e104e5e8f256aa6fdfe39b2e248cdbd7a5f90c260acbbd1b + tslib: ^2.1.0 + checksum: b3abbbfe1ddfd06fca9314b83cbd13bcddc3320429218136f75c79a4802ac430dd13873364aac1ded54fd457f8c77df332d205a92d8a1c61656565bb718c50af languageName: node linkType: hard