From b76314bdeb483d26e7df2f48e55d4ce7e4c5068a Mon Sep 17 00:00:00 2001 From: Seth Silesky <5115498+silesky@users.noreply.github.com> Date: Sat, 29 Apr 2023 17:55:52 -0500 Subject: [PATCH] wip wip wip --- .changeset/smooth-crabs-pull.md | 5 + packages/browser/package.json | 2 +- .../analytics-lazy-init.integration.test.ts | 4 +- .../analytics-pre-init.integration.test.ts | 23 +- .../__tests__/inspector.integration.test.ts | 2 +- .../src/browser/__tests__/integration.test.ts | 1 - .../page-enrichment.integration.test.ts} | 97 ++-- .../__tests__/standalone-analytics.test.ts | 50 +- packages/browser/src/browser/index.ts | 9 +- .../src/core/__tests__/auto-track.test.ts | 440 ------------------ packages/browser/src/core/analytics/index.ts | 34 +- .../auto-track/__tests__/track-form.test.ts | 196 ++++++++ .../auto-track/__tests__/track-link.test.ts | 256 ++++++++++ .../src/core/{ => auto-track}/auto-track.ts | 6 +- packages/browser/src/core/auto-track/index.ts | 1 + packages/browser/src/core/buffer/index.ts | 94 +++- packages/browser/src/core/buffer/snippet.ts | 14 +- .../src/core/events/__tests__/index.test.ts | 35 +- packages/browser/src/core/events/index.ts | 158 ++++--- .../src/core/page/__tests__/index.test.ts | 57 +++ .../browser/src/core/page/get-page-context.ts | 58 +++ packages/browser/src/core/page/index.ts | 1 + .../src/plugins/page-enrichment/index.ts | 109 ----- .../plugins/segmentio/__tests__/index.test.ts | 5 +- .../segmentio/__tests__/retries.test.ts | 3 +- .../fixtures/create-page-context.ts | 15 + .../src/test-helpers/fixtures/index.ts | 4 + packages/config/src/jest/config.js | 1 + 28 files changed, 923 insertions(+), 757 deletions(-) create mode 100644 .changeset/smooth-crabs-pull.md rename packages/browser/src/{plugins/page-enrichment/__tests__/index.test.ts => browser/__tests__/page-enrichment.integration.test.ts} (68%) delete mode 100644 packages/browser/src/core/__tests__/auto-track.test.ts create mode 100644 packages/browser/src/core/auto-track/__tests__/track-form.test.ts create mode 100644 packages/browser/src/core/auto-track/__tests__/track-link.test.ts rename packages/browser/src/core/{ => auto-track}/auto-track.ts (96%) create mode 100644 packages/browser/src/core/auto-track/index.ts create mode 100644 packages/browser/src/core/page/__tests__/index.test.ts create mode 100644 packages/browser/src/core/page/get-page-context.ts create mode 100644 packages/browser/src/core/page/index.ts delete mode 100644 packages/browser/src/plugins/page-enrichment/index.ts create mode 100644 packages/browser/src/test-helpers/fixtures/create-page-context.ts create mode 100644 packages/browser/src/test-helpers/fixtures/index.ts diff --git a/.changeset/smooth-crabs-pull.md b/.changeset/smooth-crabs-pull.md new file mode 100644 index 000000000..67d72a0cd --- /dev/null +++ b/.changeset/smooth-crabs-pull.md @@ -0,0 +1,5 @@ +--- +'@segment/analytics-next': patch +--- + +Refactor page enrichment and remove as plugin, add page context to buffered events. diff --git a/packages/browser/package.json b/packages/browser/package.json index f9f468ce9..5e23d5c8c 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -30,7 +30,7 @@ "eslint": "yarn run -T eslint", "tsc": "yarn run -T tsc", "jest": "yarn run -T jest", - "concurrently": "yarn run -T concurrently", + "concurrently": "yarn run -T concurrently --raw", "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 && NODE_ENV=stage bash scripts/release.sh", diff --git a/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts b/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts index 526a7a972..13da527a9 100644 --- a/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts +++ b/packages/browser/src/browser/__tests__/analytics-lazy-init.integration.test.ts @@ -3,6 +3,7 @@ import unfetch from 'unfetch' import { AnalyticsBrowser } from '..' import { Analytics } from '../../core/analytics' import { createSuccess } from '../../test-helpers/factories' +import { createPageCtx } from '../../test-helpers/fixtures' jest.mock('unfetch') @@ -12,6 +13,7 @@ const mockFetchSettingsSuccessResponse = () => { .mockImplementation(() => createSuccess({ integrations: {} })) } +const pageCtxFixture = createPageCtx() describe('Lazy initialization', () => { let trackSpy: jest.SpiedFunction let fetched: jest.MockedFn @@ -27,7 +29,7 @@ describe('Lazy initialization', () => { expect(trackSpy).not.toBeCalled() analytics.load({ writeKey: 'abc' }) await track - expect(trackSpy).toBeCalledWith('foo') + expect(trackSpy).toBeCalledWith('foo', pageCtxFixture) }) it('.load method return an analytics instance', async () => { 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 612d82c61..3ee0892ae 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 @@ -3,10 +3,13 @@ import unfetch from 'unfetch' import { Analytics } from '../../core/analytics' import { Context } from '../../core/context' import * as Factory from '../../test-helpers/factories' +import { createPageCtx } from '../../test-helpers/fixtures' import { sleep } from '../../lib/sleep' import { setGlobalCDNUrl } from '../../lib/parse-cdn' import { User } from '../../core/user' +const pageCtxFixture = createPageCtx() + jest.mock('unfetch') const mockFetchSettingsSuccessResponse = () => { @@ -61,7 +64,7 @@ describe('Pre-initialization', () => { const trackCtxPromise = ajsBrowser.track('foo', { name: 'john' }) const result = await trackCtxPromise expect(result).toBeInstanceOf(Context) - expect(trackSpy).toBeCalledWith('foo', { name: 'john' }) + expect(trackSpy).toBeCalledWith('foo', { name: 'john' }, pageCtxFixture) expect(trackSpy).toBeCalledTimes(1) }) @@ -107,11 +110,11 @@ describe('Pre-initialization', () => { await Promise.all([trackCtxPromise, trackCtxPromise2, identifyCtxPromise]) - expect(trackSpy).toBeCalledWith('foo', { name: 'john' }) - expect(trackSpy).toBeCalledWith('bar', { age: 123 }) + expect(trackSpy).toBeCalledWith('foo', { name: 'john' }, pageCtxFixture) + expect(trackSpy).toBeCalledWith('bar', { age: 123 }, pageCtxFixture) expect(trackSpy).toBeCalledTimes(2) - expect(identifySpy).toBeCalledWith('hello') + expect(identifySpy).toBeCalledWith('hello', pageCtxFixture) expect(identifySpy).toBeCalledTimes(1) }) @@ -237,8 +240,8 @@ describe('Pre-initialization', () => { await AnalyticsBrowser.standalone(writeKey) await sleep(100) // the snippet does not return a promise (pre-initialization) ... it sometimes has a callback as the third argument. - expect(trackSpy).toBeCalledWith('foo') - expect(trackSpy).toBeCalledWith('bar') + expect(trackSpy).toBeCalledWith('foo', pageCtxFixture) + expect(trackSpy).toBeCalledWith('bar', pageCtxFixture) expect(trackSpy).toBeCalledTimes(2) expect(identifySpy).toBeCalledTimes(1) @@ -265,11 +268,11 @@ describe('Pre-initialization', () => { await AnalyticsBrowser.standalone(writeKey) await sleep(100) // the snippet does not return a promise (pre-initialization) ... it sometimes has a callback as the third argument. - expect(trackSpy).toBeCalledWith('foo') - expect(trackSpy).toBeCalledWith('bar') + expect(trackSpy).toBeCalledWith('foo', pageCtxFixture) + expect(trackSpy).toBeCalledWith('bar', pageCtxFixture) expect(trackSpy).toBeCalledTimes(2) - expect(identifySpy).toBeCalledWith() + expect(identifySpy).toBeCalledWith(pageCtxFixture) expect(identifySpy).toBeCalledTimes(1) expect(consoleErrorSpy).toBeCalledTimes(1) @@ -320,7 +323,7 @@ describe('Pre-initialization', () => { await onReadyPromise expect(readySpy).toHaveBeenCalledTimes(1) expect(onReadyCb).toHaveBeenCalledTimes(1) - expect(readySpy).toHaveBeenCalledWith(expect.any(Function)) + expect(readySpy).toHaveBeenCalledWith(onReadyCb) }) test('Should work with "on" events if a track event is called after load is complete', async () => { diff --git a/packages/browser/src/browser/__tests__/inspector.integration.test.ts b/packages/browser/src/browser/__tests__/inspector.integration.test.ts index c56e6d986..11baefb00 100644 --- a/packages/browser/src/browser/__tests__/inspector.integration.test.ts +++ b/packages/browser/src/browser/__tests__/inspector.integration.test.ts @@ -55,7 +55,7 @@ describe('Inspector', () => { await deliveryPromise - expect(enrichedFn).toHaveBeenCalledTimes(2) + expect(enrichedFn).toHaveBeenCalledTimes(1) expect(deliveredFn).toHaveBeenCalledTimes(1) }) diff --git a/packages/browser/src/browser/__tests__/integration.test.ts b/packages/browser/src/browser/__tests__/integration.test.ts index d4f84e92e..f8190771f 100644 --- a/packages/browser/src/browser/__tests__/integration.test.ts +++ b/packages/browser/src/browser/__tests__/integration.test.ts @@ -521,7 +521,6 @@ describe('Dispatch', () => { "message_dispatched", "plugin_time", "plugin_time", - "plugin_time", "message_delivered", "plugin_time", "delivered", diff --git a/packages/browser/src/plugins/page-enrichment/__tests__/index.test.ts b/packages/browser/src/browser/__tests__/page-enrichment.integration.test.ts similarity index 68% rename from packages/browser/src/plugins/page-enrichment/__tests__/index.test.ts rename to packages/browser/src/browser/__tests__/page-enrichment.integration.test.ts index 584f61243..18d19212f 100644 --- a/packages/browser/src/plugins/page-enrichment/__tests__/index.test.ts +++ b/packages/browser/src/browser/__tests__/page-enrichment.integration.test.ts @@ -1,12 +1,13 @@ -import { Analytics } from '../../../core/analytics' -import { pageEnrichment, pageDefaults } from '..' -import { pick } from '../../../lib/pick' +import { Analytics } from '../../core/analytics' +import { pick } from '../../lib/pick' +import { PageContext, PAGE_CTX_DISCRIMINANT } from '../../core/page' let ajs: Analytics const helpers = { - get pageProps() { + get pageProps(): PageContext { return { + __type: PAGE_CTX_DISCRIMINANT, url: 'http://foo.com/bar?foo=hello_world', path: '/bar', search: '?foo=hello_world', @@ -21,8 +22,6 @@ describe('Page Enrichment', () => { ajs = new Analytics({ writeKey: 'abc_123', }) - - await ajs.register(pageEnrichment) }) test('enriches page calls', async () => { @@ -30,6 +29,7 @@ describe('Page Enrichment', () => { expect(ctx.event.properties).toMatchInlineSnapshot(` Object { + "__type": "page_ctx", "name": "Checkout", "path": "/", "referrer": "", @@ -47,6 +47,7 @@ describe('Page Enrichment', () => { expect(ctx.event.context?.page).toMatchInlineSnapshot(` Object { + "__type": "page_ctx", "path": "/", "referrer": "", "search": "", @@ -107,27 +108,29 @@ describe('Page Enrichment', () => { ) expect(ctx.event.context?.page).toMatchInlineSnapshot(` - Object { - "path": "/", - "referrer": "", - "search": "", - "title": "", - "url": "not-localhost", - } - `) + Object { + "__type": "page_ctx", + "path": "/", + "referrer": "", + "search": "", + "title": "", + "url": "not-localhost", + } + `) }) - test('enriches page events using properties', async () => { + test.only('enriches page events using properties', async () => { const ctx = await ajs.page('My event', { banana: 'phone', referrer: 'foo' }) expect(ctx.event.context?.page).toMatchInlineSnapshot(` - Object { - "path": "/", - "referrer": "foo", - "search": "", - "title": "", - "url": "http://localhost/", - } - `) + Object { + "__type": "page_ctx", + "path": "/", + "referrer": "foo", + "search": "", + "title": "", + "url": "http://localhost/", + } + `) }) test('in page events, event.name overrides event.properties.name', async () => { @@ -151,6 +154,7 @@ describe('Page Enrichment', () => { expect(ctx.event.context?.page).toMatchInlineSnapshot(` Object { + "__type": "page_ctx", "path": "/", "referrer": "", "search": "", @@ -176,50 +180,3 @@ describe('Page Enrichment', () => { expect(called).toBe(true) }) }) - -describe('pageDefaults', () => { - const el = document.createElement('link') - el.setAttribute('rel', 'canonical') - - beforeEach(() => { - el.setAttribute('href', '') - document.clear() - }) - - afterEach(() => { - jest.restoreAllMocks() - }) - - it('handles no canonical links', () => { - const defs = pageDefaults() - expect(defs.url).not.toBeNull() - }) - - it('handles canonical links', () => { - el.setAttribute('href', 'http://www.segment.local') - document.body.appendChild(el) - const defs = pageDefaults() - expect(defs.url).toEqual('http://www.segment.local') - }) - - it('handles canonical links with a path', () => { - el.setAttribute('href', 'http://www.segment.local/test') - document.body.appendChild(el) - const defs = pageDefaults() - expect(defs.url).toEqual('http://www.segment.local/test') - expect(defs.path).toEqual('/test') - }) - - it('handles canonical links with search params in the url', () => { - el.setAttribute('href', 'http://www.segment.local?test=true') - document.body.appendChild(el) - const defs = pageDefaults() - expect(defs.url).toEqual('http://www.segment.local?test=true') - }) - - it('if canonical does not exist, returns fallback', () => { - document.body.appendChild(el) - const defs = pageDefaults() - expect(defs.url).toEqual(window.location.href) - }) -}) diff --git a/packages/browser/src/browser/__tests__/standalone-analytics.test.ts b/packages/browser/src/browser/__tests__/standalone-analytics.test.ts index eec550fce..9277ef8a7 100644 --- a/packages/browser/src/browser/__tests__/standalone-analytics.test.ts +++ b/packages/browser/src/browser/__tests__/standalone-analytics.test.ts @@ -8,6 +8,7 @@ import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted' import { sleep } from '../../lib/sleep' import * as Factory from '../../test-helpers/factories' import { EventQueue } from '../../core/queue/event-queue' +import { createPageCtx } from '../../test-helpers/fixtures' const track = jest.fn() const identify = jest.fn() @@ -38,6 +39,8 @@ jest.mock('unfetch', () => { return jest.fn() }) +const pageCtxFixture = createPageCtx({ url: 'https://segment.com/' }) + describe('standalone bundle', () => { const segmentDotCom = `foo` @@ -65,6 +68,7 @@ describe('standalone bundle', () => { `.trim() const virtualConsole = new jsdom.VirtualConsole() + const jsd = new JSDOM(html, { runScripts: 'dangerously', resources: 'usable', @@ -156,12 +160,20 @@ describe('standalone bundle', () => { await sleep(0) - expect(track).toHaveBeenCalledWith('fruit basket', { - fruits: ['🍌', '🍇'], - }) - expect(identify).toHaveBeenCalledWith('netto', { - employer: 'segment', - }) + expect(track).toHaveBeenCalledWith( + 'fruit basket', + { + fruits: ['🍌', '🍇'], + }, + pageCtxFixture + ) + expect(identify).toHaveBeenCalledWith( + 'netto', + { + employer: 'segment', + }, + pageCtxFixture + ) expect(page).toHaveBeenCalled() }) @@ -267,13 +279,25 @@ describe('standalone bundle', () => { await sleep(0) - expect(track).toHaveBeenCalledWith('fruit basket', { - fruits: ['🍌', '🍇'], - }) - expect(track).toHaveBeenCalledWith('race conditions', { foo: 'bar' }) - expect(identify).toHaveBeenCalledWith('netto', { - employer: 'segment', - }) + expect(track).toHaveBeenCalledWith( + 'fruit basket', + { + fruits: ['🍌', '🍇'], + }, + pageCtxFixture + ) + expect(track).toHaveBeenCalledWith( + 'race conditions', + { foo: 'bar' }, + pageCtxFixture + ) + expect(identify).toHaveBeenCalledWith( + 'netto', + { + employer: 'segment', + }, + pageCtxFixture + ) expect(page).toHaveBeenCalled() }) diff --git a/packages/browser/src/browser/index.ts b/packages/browser/src/browser/index.ts index bfbf92f76..747c54ae2 100644 --- a/packages/browser/src/browser/index.ts +++ b/packages/browser/src/browser/index.ts @@ -9,7 +9,6 @@ import { Plugin } from '../core/plugin' import { MetricsOptions } from '../core/stats/remote-metrics' import { mergedOptions } from '../lib/merged-options' import { createDeferred } from '../lib/create-deferred' -import { pageEnrichment } from '../plugins/page-enrichment' import { remoteLoader, RemotePlugin } from '../plugins/remote-loader' import type { RoutingRule } from '../plugins/routing-middleware' import { segmentio, SegmentioSettings } from '../plugins/segmentio' @@ -22,7 +21,6 @@ import { flushSetAnonymousID, flushOn, } from '../core/buffer' -import { popSnippetWindowBuffer } from '../core/buffer/snippet' import { ClassicIntegrationSource } from '../plugins/ajs-destination/types' import { attachInspector } from '../core/inspector' import { Stats } from '../core/stats' @@ -127,7 +125,7 @@ function flushPreBuffer( analytics: Analytics, buffer: PreInitMethodCallBuffer ): void { - buffer.push(...popSnippetWindowBuffer()) + buffer.fetchSnippetWindowBuffer() flushSetAnonymousID(analytics, buffer) flushOn(analytics, buffer) } @@ -141,9 +139,9 @@ async function flushFinalBuffer( ): Promise { // Call popSnippetWindowBuffer before each flush task since there may be // analytics calls during async function calls. - buffer.push(...popSnippetWindowBuffer()) + buffer.fetchSnippetWindowBuffer() await flushAddSourceMiddleware(analytics, buffer) - buffer.push(...popSnippetWindowBuffer()) + buffer.fetchSnippetWindowBuffer() flushAnalyticsCallsInNewTask(analytics, buffer) // Clear buffer, just in case analytics is loaded twice; we don't want to fire events off again. buffer.clear() @@ -209,7 +207,6 @@ async function registerPlugins( const toRegister = [ validation, - pageEnrichment, ...plugins, ...legacyDestinations, ...remotePlugins, diff --git a/packages/browser/src/core/__tests__/auto-track.test.ts b/packages/browser/src/core/__tests__/auto-track.test.ts deleted file mode 100644 index 1c9d27ebd..000000000 --- a/packages/browser/src/core/__tests__/auto-track.test.ts +++ /dev/null @@ -1,440 +0,0 @@ -import { JSDOM } from 'jsdom' -import { Analytics } from '../analytics' - -const sleep = (time: number): Promise => - new Promise((resolve) => { - setTimeout(resolve, time) - }) - -async function resolveWhen( - condition: () => boolean, - timeout?: number -): Promise { - return new Promise((resolve, _reject) => { - if (condition()) { - resolve() - return - } - - const check = () => - setTimeout(() => { - if (condition()) { - resolve() - } else { - check() - } - }, timeout) - - check() - }) -} - -describe('track helpers', () => { - describe('trackLink', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let link: any - let wrap: SVGSVGElement - let svg: SVGAElement - - let analytics = new Analytics({ writeKey: 'foo' }) - let mockTrack = jest.spyOn(analytics, 'track') - - beforeEach(() => { - analytics = new Analytics({ writeKey: 'foo' }) - - // @ts-ignore - global.jQuery = require('jquery') - - const jsd = new JSDOM('', { - runScripts: 'dangerously', - resources: 'usable', - }) - // eslint-disable-next-line no-global-assign - document = jsd.window.document - - jest.spyOn(console, 'error').mockImplementationOnce(() => {}) - - document.querySelector('html')!.innerHTML = ` - - - -
-
- -
-
- - ` - - link = document.getElementById('foo') - wrap = document.createElementNS('http://www.w3.org/2000/svg', 'svg') - svg = document.createElementNS('http://www.w3.org/2000/svg', 'a') - wrap.appendChild(svg) - document.body.appendChild(wrap) - - jest.spyOn(window, 'location', 'get').mockReturnValue({ - ...window.location, - }) - - mockTrack = jest.spyOn(analytics, 'track') - - // We need to mock the track function for the .catch() call not to break when testing - // eslint-disable-next-line @typescript-eslint/unbound-method - mockTrack.mockImplementation(Analytics.prototype.track) - }) - - it('should respect options object', async () => { - await analytics.trackLink( - link!, - 'foo', - {}, - { context: { ip: '0.0.0.0' } } - ) - link.click() - - expect(mockTrack).toHaveBeenCalledWith( - 'foo', - {}, - { context: { ip: '0.0.0.0' } } - ) - }) - - it('should stay on same page with blank href', async () => { - link.href = '' - await analytics.trackLink(link!, 'foo') - link.click() - - expect(mockTrack).toHaveBeenCalled() - expect(window.location.href).toBe('http://localhost/') - }) - - it('should work with nested link', async () => { - const nested = document.getElementById('bar') - await analytics.trackLink(nested, 'foo') - nested!.click() - - expect(mockTrack).toHaveBeenCalled() - - await resolveWhen(() => window.location.href === 'bar.com') - expect(window.location.href).toBe('bar.com') - }) - - it('should make a track call', async () => { - await analytics.trackLink(link!, 'foo') - link.click() - - expect(mockTrack).toHaveBeenCalled() - }) - - it('should still navigate even if the track call fails', async () => { - mockTrack.mockClear() - - let rejected = false - mockTrack.mockImplementationOnce(() => { - rejected = true - return Promise.reject(new Error('boo!')) - }) - - const nested = document.getElementById('bar') - await analytics.trackLink(nested, 'foo') - nested!.click() - - await resolveWhen(() => rejected) - await resolveWhen(() => window.location.href === 'bar.com') - expect(window.location.href).toBe('bar.com') - }) - - it('should still navigate even if the track call times out', async () => { - mockTrack.mockClear() - - let timedOut = false - mockTrack.mockImplementationOnce(async () => { - await sleep(600) - timedOut = true - return Promise.resolve() as any - }) - - const nested = document.getElementById('bar') - await analytics.trackLink(nested, 'foo') - nested!.click() - - await resolveWhen(() => window.location.href === 'bar.com') - expect(window.location.href).toBe('bar.com') - expect(timedOut).toBe(false) - - await resolveWhen(() => timedOut) - }) - - it('should accept a jquery object for an element', async () => { - const $link = jQuery(link) - await analytics.trackLink($link, 'foo') - link.click() - expect(mockTrack).toBeCalled() - }) - - it('accepts array of elements', async () => { - const links = [link, link] - await analytics.trackLink(links, 'foo') - link.click() - - expect(mockTrack).toHaveBeenCalled() - }) - - it('should send an event and properties', async () => { - await analytics.trackLink(link, 'event', { property: true }) - link.click() - - expect(mockTrack).toBeCalledWith('event', { property: true }, {}) - }) - - it('should accept an event function', async () => { - function event(el: Element): string { - return el.nodeName - } - await analytics.trackLink(link, event, { foo: 'bar' }) - link.click() - - expect(mockTrack).toBeCalledWith('A', { foo: 'bar' }, {}) - }) - - it('should accept a properties function', async () => { - function properties(el: Record): Record { - return { type: el.nodeName } - } - await analytics.trackLink(link, 'event', properties) - link.click() - - expect(mockTrack).toBeCalledWith('event', { type: 'A' }, {}) - }) - - it('should load an href on click', async () => { - link.href = '#test' - await analytics.trackLink(link, 'foo') - link.click() - - await resolveWhen(() => window.location.href === '#test') - expect(global.window.location.href).toBe('#test') - }) - - it('should only navigate after the track call has been completed', async () => { - link.href = '#test' - await analytics.trackLink(link, 'foo') - link.click() - - await resolveWhen(() => mockTrack.mock.calls.length === 1) - await resolveWhen(() => window.location.href === '#test') - - expect(global.window.location.href).toBe('#test') - }) - - it('should support svg .href attribute', async () => { - svg.setAttribute('href', '#svg') - await analytics.trackLink(svg, 'foo') - const clickEvent = new Event('click') - svg.dispatchEvent(clickEvent) - - await resolveWhen(() => window.location.href === '#svg') - expect(global.window.location.href).toBe('#svg') - }) - - it('should fallback to getAttributeNS', async () => { - svg.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#svg') - await analytics.trackLink(svg, 'foo') - const clickEvent = new Event('click') - svg.dispatchEvent(clickEvent) - - await resolveWhen(() => window.location.href === '#svg') - expect(global.window.location.href).toBe('#svg') - }) - - it('should support xlink:href', async () => { - svg.setAttribute('xlink:href', '#svg') - await analytics.trackLink(svg, 'foo') - const clickEvent = new Event('click') - svg.dispatchEvent(clickEvent) - - await resolveWhen(() => window.location.href === '#svg') - expect(global.window.location.href).toBe('#svg') - }) - - it('should not load an href for a link with a blank target', async () => { - link.href = '/base/test/support/mock.html' - link.target = '_blank' - await analytics.trackLink(link, 'foo') - link.click() - - await sleep(300) - expect(global.window.location.href).not.toBe('#test') - }) - }) - - describe('trackForm', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let form: any - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let submit: any - - const analytics = new Analytics({ writeKey: 'foo' }) - let mockTrack = jest.spyOn(analytics, 'track') - - beforeEach(() => { - document.querySelector('html')!.innerHTML = ` - - -
- -
- - ` - form = document.getElementById('form') - submit = document.getElementById('submit') - - // @ts-ignore - global.jQuery = require('jquery') - - mockTrack = jest.spyOn(analytics, 'track') - // eslint-disable-next-line @typescript-eslint/unbound-method - mockTrack.mockImplementation(Analytics.prototype.track) - }) - - afterEach(() => { - window.location.hash = '' - document.body.removeChild(form) - }) - - it('should not error or send track event on null form', async () => { - const form = document.getElementById('fake-form') as HTMLFormElement - - await analytics.trackForm(form, 'Signed Up', { - plan: 'Premium', - revenue: 99.0, - }) - submit.click() - expect(mockTrack).not.toBeCalled() - }) - - it('should respect options object', async () => { - await analytics.trackForm(form, 'foo', {}, { context: { ip: '0.0.0.0' } }) - submit.click() - - expect(mockTrack).toHaveBeenCalledWith( - 'foo', - {}, - { context: { ip: '0.0.0.0' } } - ) - }) - - it('should trigger a track on a form submit', async () => { - await analytics.trackForm(form, 'foo') - submit.click() - expect(mockTrack).toBeCalled() - }) - - it('should accept a jquery object for an element', async () => { - await analytics.trackForm(form, 'foo') - submit.click() - expect(mockTrack).toBeCalled() - }) - - it('should not accept a string for an element', async () => { - try { - // @ts-expect-error - await analytics.trackForm('foo') - submit.click() - } catch (e) { - expect(e instanceof TypeError).toBe(true) - } - expect(mockTrack).not.toBeCalled() - }) - - it('should send an event and properties', async () => { - await analytics.trackForm(form, 'event', { property: true }) - submit.click() - expect(mockTrack).toBeCalledWith('event', { property: true }, {}) - }) - - it('should accept an event function', async () => { - function event(): string { - return 'event' - } - await analytics.trackForm(form, event, { foo: 'bar' }) - submit.click() - expect(mockTrack).toBeCalledWith('event', { foo: 'bar' }, {}) - }) - - it('should accept a properties function', async () => { - function properties(): Record { - return { property: true } - } - await analytics.trackForm(form, 'event', properties) - submit.click() - expect(mockTrack).toBeCalledWith('event', { property: true }, {}) - }) - - it('should call submit after a timeout', async () => { - const submitSpy = jest.spyOn(form, 'submit') - const mockedTrack = jest.fn() - - // eslint-disable-next-line @typescript-eslint/unbound-method - mockedTrack.mockImplementation(Analytics.prototype.track) - - analytics.track = mockedTrack - await analytics.trackForm(form, 'foo') - - submit.click() - - await sleep(500) - - expect(submitSpy).toHaveBeenCalled() - }) - - it('should trigger an existing submit handler', async () => { - const submitPromise = new Promise((resolve) => { - form.addEventListener('submit', () => { - resolve() - }) - }) - - await analytics.trackForm(form, 'foo') - submit.click() - await submitPromise - }) - - it('should trigger an existing jquery submit handler', async () => { - const $form = jQuery(form) - - const submitPromise = new Promise((resolve) => { - $form.submit(function () { - resolve() - }) - }) - - await analytics.trackForm(form, 'foo') - submit.click() - await submitPromise - }) - - it('should track on a form submitted via jquery', async () => { - const $form = jQuery(form) - - await analytics.trackForm(form, 'foo') - $form.submit() - - expect(mockTrack).toBeCalled() - }) - - it('should trigger an existing jquery submit handler on a form submitted via jquery', async () => { - const $form = jQuery(form) - - const submitPromise = new Promise((resolve) => { - $form.submit(function () { - resolve() - }) - }) - - await analytics.trackForm(form, 'foo') - $form.submit() - await submitPromise - }) - }) -}) diff --git a/packages/browser/src/core/analytics/index.ts b/packages/browser/src/core/analytics/index.ts index f597303e8..0db14a58a 100644 --- a/packages/browser/src/core/analytics/index.ts +++ b/packages/browser/src/core/analytics/index.ts @@ -48,6 +48,8 @@ import { version } from '../../generated/version' import { PriorityQueue } from '../../lib/priority-queue' import { getGlobal } from '../../lib/get-global' import { AnalyticsClassic, AnalyticsCore } from './interfaces' +import { hasPageContextAsLastArg } from '../buffer' +import { PageContext } from '../page' const deprecationWarning = 'This is being deprecated and will be not be available in future releases of Analytics JS' @@ -109,6 +111,13 @@ function _stub(this: never) { console.warn(deprecationWarning) } +const popPageContext = (args: any[]): PageContext | undefined => { + if (hasPageContextAsLastArg(args)) { + const ctx = args.pop() as PageContext + return ctx + } +} + export class Analytics extends Emitter implements AnalyticsCore, AnalyticsClassic @@ -166,7 +175,6 @@ export class Analytics this.eventFactory = new EventFactory(this._user) this.integrations = options?.integrations ?? {} this.options = options ?? {} - autoBind(this) } @@ -179,13 +187,15 @@ export class Analytics } async track(...args: EventParams): Promise { + const pageCtx = popPageContext(args) const [name, data, opts, cb] = resolveArguments(...args) const segmentEvent = this.eventFactory.track( name, data as EventProperties, opts, - this.integrations + this.integrations, + pageCtx ) return this._dispatch(segmentEvent, cb).then((ctx) => { @@ -195,6 +205,7 @@ export class Analytics } async page(...args: PageParams): Promise { + const pageCtx = popPageContext(args) const [category, page, properties, options, callback] = resolvePageArguments(...args) @@ -203,7 +214,8 @@ export class Analytics page, properties, options, - this.integrations + this.integrations, + pageCtx ) return this._dispatch(segmentEvent, callback).then((ctx) => { @@ -213,6 +225,7 @@ export class Analytics } async identify(...args: IdentifyParams): Promise { + const pageCtx = popPageContext(args) const [id, _traits, options, callback] = resolveUserArguments(this._user)( ...args ) @@ -222,7 +235,8 @@ export class Analytics this._user.id(), this._user.traits(), options, - this.integrations + this.integrations, + pageCtx ) return this._dispatch(segmentEvent, callback).then((ctx) => { @@ -239,6 +253,7 @@ export class Analytics group(): Group group(...args: GroupParams): Promise group(...args: GroupParams): Promise | Group { + const pageCtx = popPageContext(args) if (args.length === 0) { return this._group } @@ -255,7 +270,8 @@ export class Analytics groupId, groupTraits, options, - this.integrations + this.integrations, + pageCtx ) return this._dispatch(segmentEvent, callback).then((ctx) => { @@ -265,12 +281,14 @@ export class Analytics } async alias(...args: AliasParams): Promise { + const pageCtx = popPageContext(args) const [to, from, options, callback] = resolveAliasArguments(...args) const segmentEvent = this.eventFactory.alias( to, from, options, - this.integrations + this.integrations, + pageCtx ) return this._dispatch(segmentEvent, callback).then((ctx) => { this.emit('alias', to, from, ctx.event.options) @@ -279,6 +297,7 @@ export class Analytics } async screen(...args: PageParams): Promise { + const pageCtx = popPageContext(args) const [category, page, properties, options, callback] = resolvePageArguments(...args) @@ -287,7 +306,8 @@ export class Analytics page, properties, options, - this.integrations + this.integrations, + pageCtx ) return this._dispatch(segmentEvent, callback).then((ctx) => { this.emit( diff --git a/packages/browser/src/core/auto-track/__tests__/track-form.test.ts b/packages/browser/src/core/auto-track/__tests__/track-form.test.ts new file mode 100644 index 000000000..ba49924d2 --- /dev/null +++ b/packages/browser/src/core/auto-track/__tests__/track-form.test.ts @@ -0,0 +1,196 @@ +import { Analytics } from '../../analytics' +import { createPageCtx } from '../../../test-helpers/fixtures' +import { sleep } from '../../../lib/sleep' + +const defaultContext = { + page: { ...createPageCtx() }, +} + +let analytics: Analytics +let mockTrack: jest.SpiedFunction +const ogLocation = { + ...global.window.location, +} + +beforeEach(() => { + // @ts-ignore + global.jQuery = require('jquery') + + jest.spyOn(console, 'error').mockImplementationOnce(() => {}) + Object.defineProperty(window, 'location', { + value: ogLocation, + writable: true, + }) + mockTrack = jest.spyOn(Analytics.prototype, 'track') + analytics = new Analytics({ writeKey: 'foo' }) +}) + +describe('trackForm', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let form: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let submit: any + + beforeEach(() => { + document.querySelector('html')!.innerHTML = ` + + +
+ +
+ + ` + form = document.getElementById('form') + submit = document.getElementById('submit') + + // @ts-ignore + global.jQuery = require('jquery') + }) + + afterEach(() => { + document.querySelector('html')!.innerHTML = '' + }) + + it('should have the correct page context', async () => { + window.location.search = '?foo=123' + await analytics.trackForm(form, 'foo', {}, { context: { ip: '0.0.0.0' } }) + submit.click() + console.log(window.location.href) + const [, , properties] = mockTrack.mock.lastCall as any[] + + expect((properties.context as any).page).toEqual({ + ...defaultContext.page, + search: '?foo=123', + }) + }) + + it('should not error or send track event on null form', async () => { + const form = document.getElementById('fake-form') as HTMLFormElement + + await analytics.trackForm(form, 'Signed Up', { + plan: 'Premium', + revenue: 99.0, + }) + submit.click() + expect(mockTrack).not.toBeCalled() + }) + + it('should respect options object', async () => { + await analytics.trackForm(form, 'foo', {}, { context: { ip: '0.0.0.0' } }) + submit.click() + + expect(mockTrack).toHaveBeenCalledWith( + 'foo', + {}, + { context: expect.objectContaining({ ip: '0.0.0.0' }) } + ) + }) + + it('should trigger a track on a form submit', async () => { + await analytics.trackForm(form, 'foo') + submit.click() + expect(mockTrack).toBeCalled() + }) + + it('should accept a jquery object for an element', async () => { + await analytics.trackForm(form, 'foo') + submit.click() + expect(mockTrack).toBeCalled() + }) + + it('should not accept a string for an element', async () => { + try { + // @ts-expect-error + await analytics.trackForm('foo') + submit.click() + } catch (e) { + expect(e instanceof TypeError).toBe(true) + } + expect(mockTrack).not.toBeCalled() + }) + + it('should send an event and properties', async () => { + await analytics.trackForm(form, 'event', { property: true }) + submit.click() + expect(mockTrack).toBeCalledWith('event', { property: true }, {}) + }) + + it('should accept an event function', async () => { + function event(): string { + return 'event' + } + await analytics.trackForm(form, event, { foo: 'bar' }) + submit.click() + expect(mockTrack).toBeCalledWith('event', { foo: 'bar' }, {}) + }) + + it('should accept a properties function', async () => { + function properties(): Record { + return { property: true } + } + await analytics.trackForm(form, 'event', properties) + submit.click() + expect(mockTrack).toBeCalledWith('event', { property: true }, {}) + }) + + it('should call submit after a timeout', async () => { + const submitSpy = jest.spyOn(form, 'submit') + + await analytics.trackForm(form, 'foo') + + submit.click() + + await sleep(300) + + expect(submitSpy).toHaveBeenCalled() + }) + + it('should trigger an existing submit handler', async () => { + const submitPromise = new Promise((resolve) => { + form.addEventListener('submit', () => { + resolve() + }) + }) + + await analytics.trackForm(form, 'foo') + submit.click() + await submitPromise + }) + + it('should trigger an existing jquery submit handler', async () => { + const $form = jQuery(form) + + const submitPromise = new Promise((resolve) => { + $form.submit(function () { + resolve() + }) + }) + + await analytics.trackForm(form, 'foo') + submit.click() + await submitPromise + }) + + it('should track on a form submitted via jquery', async () => { + const $form = jQuery(form) + + await analytics.trackForm(form, 'foo') + $form.submit() + + expect(mockTrack).toBeCalled() + }) + + it('should trigger an existing jquery submit handler on a form submitted via jquery', async () => { + const $form = jQuery(form) + + const submitPromise = new Promise((resolve) => { + $form.submit(function () { + resolve() + }) + }) + + await analytics.trackForm(form, 'foo') + $form.submit() + await submitPromise + }) +}) diff --git a/packages/browser/src/core/auto-track/__tests__/track-link.test.ts b/packages/browser/src/core/auto-track/__tests__/track-link.test.ts new file mode 100644 index 000000000..360c097af --- /dev/null +++ b/packages/browser/src/core/auto-track/__tests__/track-link.test.ts @@ -0,0 +1,256 @@ +import { Analytics } from '../../analytics' +import { createPageCtx } from '../../../test-helpers/fixtures' +import { sleep } from '../../../lib/sleep' + +async function resolveWhen( + condition: () => boolean, + timeout?: number +): Promise { + return new Promise((resolve, _reject) => { + if (condition()) { + resolve() + return + } + + const check = () => + setTimeout(() => { + if (condition()) { + resolve() + } else { + check() + } + }, timeout) + + check() + }) +} +const defaultContext = { + page: { ...createPageCtx() }, +} + +const ogLocation = { + ...global.window.location, +} + +let analytics: Analytics +let mockTrack: jest.SpiedFunction +beforeEach(() => { + Object.defineProperty(window, 'location', { + value: ogLocation, + writable: true, + }) + mockTrack = jest.spyOn(Analytics.prototype, 'track') + analytics = new Analytics({ writeKey: 'foo' }) +}) +describe('trackLink', () => { + let link: any + let wrap: SVGSVGElement + let svg: SVGAElement + + beforeEach(() => { + // @ts-ignore + global.jQuery = require('jquery') + + jest.spyOn(console, 'error').mockImplementationOnce(() => {}) + + document.querySelector('html')!.innerHTML = ` + + + +
+
+ +
+
+ + ` + + link = document.getElementById('foo') + wrap = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + svg = document.createElementNS('http://www.w3.org/2000/svg', 'a') + wrap.appendChild(svg) + document.body.appendChild(wrap) + }) + + afterEach(() => { + document.querySelector('html')!.innerHTML = '' + }) + it('should respect options object', async () => { + await analytics.trackLink(link!, 'foo', {}, { context: { ip: '0.0.0.0' } }) + link.click() + + expect(mockTrack).toHaveBeenCalledWith( + 'foo', + {}, + { context: { ip: '0.0.0.0', ...defaultContext } } + ) + }) + + it('should stay on same page with blank href', async () => { + link.href = '' + await analytics.trackLink(link!, 'foo') + link.click() + + expect(mockTrack).toHaveBeenCalled() + expect(window.location.href).toBe('http://localhost/') + }) + + it('should work with nested link', async () => { + const nested = document.getElementById('bar') + await analytics.trackLink(nested, 'foo') + nested!.click() + + expect(mockTrack).toHaveBeenCalled() + + await resolveWhen(() => window.location.href === 'bar.com') + expect(window.location.href).toBe('bar.com') + }) + + it('should make a track call', async () => { + await analytics.trackLink(link!, 'foo') + link.click() + + expect(mockTrack).toHaveBeenCalled() + }) + + it('should still navigate even if the track call fails', async () => { + mockTrack.mockClear() + + let rejected = false + mockTrack.mockImplementationOnce(() => { + rejected = true + return Promise.reject(new Error('boo!')) + }) + + const nested = document.getElementById('bar') + await analytics.trackLink(nested, 'foo') + nested!.click() + + await resolveWhen(() => rejected) + await resolveWhen(() => window.location.href === 'bar.com') + expect(window.location.href).toBe('bar.com') + }) + + it('should still navigate even if the track call times out', async () => { + mockTrack.mockClear() + + let timedOut = false + mockTrack.mockImplementationOnce(async () => { + await sleep(600) + timedOut = true + return Promise.resolve() as any + }) + + const nested = document.getElementById('bar') + await analytics.trackLink(nested, 'foo') + nested!.click() + + await resolveWhen(() => window.location.href === 'bar.com') + expect(window.location.href).toBe('bar.com') + expect(timedOut).toBe(false) + + await resolveWhen(() => timedOut) + }) + + it('should accept a jquery object for an element', async () => { + const $link = jQuery(link) + await analytics.trackLink($link, 'foo') + link.click() + expect(mockTrack).toBeCalled() + }) + + it('accepts array of elements', async () => { + const links = [link, link] + await analytics.trackLink(links, 'foo') + link.click() + + expect(mockTrack).toHaveBeenCalled() + }) + + it('should send an event and properties', async () => { + await analytics.trackLink(link, 'event', { property: true }) + link.click() + + expect(mockTrack).toBeCalledWith('event', { property: true }, {}) + }) + + it('should accept an event function', async () => { + function event(el: Element): string { + return el.nodeName + } + await analytics.trackLink(link, event, { foo: 'bar' }) + link.click() + + expect(mockTrack).toBeCalledWith('A', { foo: 'bar' }, {}) + }) + + it('should accept a properties function', async () => { + function properties(el: Record): Record { + return { type: el.nodeName } + } + await analytics.trackLink(link, 'event', properties) + link.click() + + expect(mockTrack).toBeCalledWith('event', { type: 'A' }, {}) + }) + + it('should load an href on click', async () => { + link.href = '#test' + await analytics.trackLink(link, 'foo') + link.click() + + await resolveWhen(() => window.location.href === '#test') + expect(global.window.location.href).toBe('#test') + }) + + it('should only navigate after the track call has been completed', async () => { + link.href = '#test' + await analytics.trackLink(link, 'foo') + link.click() + + await resolveWhen(() => mockTrack.mock.calls.length === 1) + await resolveWhen(() => window.location.href === '#test') + + expect(global.window.location.href).toBe('#test') + }) + + it('should support svg .href attribute', async () => { + svg.setAttribute('href', '#svg') + await analytics.trackLink(svg, 'foo') + const clickEvent = new Event('click') + svg.dispatchEvent(clickEvent) + + await resolveWhen(() => window.location.href === '#svg') + expect(global.window.location.href).toBe('#svg') + }) + + it('should fallback to getAttributeNS', async () => { + svg.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '#svg') + await analytics.trackLink(svg, 'foo') + const clickEvent = new Event('click') + svg.dispatchEvent(clickEvent) + + await resolveWhen(() => window.location.href === '#svg') + expect(global.window.location.href).toBe('#svg') + }) + + it('should support xlink:href', async () => { + svg.setAttribute('xlink:href', '#svg') + await analytics.trackLink(svg, 'foo') + const clickEvent = new Event('click') + svg.dispatchEvent(clickEvent) + + await resolveWhen(() => window.location.href === '#svg') + expect(global.window.location.href).toBe('#svg') + }) + + it('should not load an href for a link with a blank target', async () => { + link.href = '/base/test/support/mock.html' + link.target = '_blank' + await analytics.trackLink(link, 'foo') + link.click() + + await sleep(300) + expect(global.window.location.href).not.toBe('#test') + }) +}) diff --git a/packages/browser/src/core/auto-track.ts b/packages/browser/src/core/auto-track/auto-track.ts similarity index 96% rename from packages/browser/src/core/auto-track.ts rename to packages/browser/src/core/auto-track/auto-track.ts index 0a195f608..4aac6d078 100644 --- a/packages/browser/src/core/auto-track.ts +++ b/packages/browser/src/core/auto-track/auto-track.ts @@ -1,6 +1,6 @@ -import { Analytics } from './analytics' -import { EventProperties, Options } from './events' -import { pTimeout } from './callback' +import { Analytics } from '../analytics' +import { EventProperties, Options } from '../events' +import { pTimeout } from '../callback' // Check if a user is opening the link in a new tab function userNewTab(event: Event): boolean { diff --git a/packages/browser/src/core/auto-track/index.ts b/packages/browser/src/core/auto-track/index.ts new file mode 100644 index 000000000..3b993cba8 --- /dev/null +++ b/packages/browser/src/core/auto-track/index.ts @@ -0,0 +1 @@ +export * from './auto-track' diff --git a/packages/browser/src/core/buffer/index.ts b/packages/browser/src/core/buffer/index.ts index d7b42edb0..cfc6aca63 100644 --- a/packages/browser/src/core/buffer/index.ts +++ b/packages/browser/src/core/buffer/index.ts @@ -3,6 +3,8 @@ import { Context } from '../context' import { isThenable } from '../../lib/is-thenable' import { AnalyticsBrowserCore } from '../analytics/interfaces' import { version } from '../../generated/version' +import { isPlainObject } from '@segment/analytics-core' +import { getPageContext, isPageContext, PageContext } from '../page' /** * The names of any AnalyticsBrowser methods that also exist on Analytics @@ -80,10 +82,16 @@ export const flushAnalyticsCallsInNewTask = ( }) } +export const hasPageContextAsLastArg = ( + args: unknown[] +): args is [...unknown[], PageContext] | [PageContext] => { + const lastArg = args[args.length - 1] + return isPlainObject(lastArg) && isPageContext(lastArg) +} /** * Represents a buffered method call that occurred before initialization. */ -export interface PreInitMethodCall< +export class PreInitMethodCall< MethodName extends PreInitMethodName = PreInitMethodName > { method: MethodName @@ -91,10 +99,33 @@ export interface PreInitMethodCall< called: boolean resolve: (v: ReturnType) => void reject: (reason: any) => void + constructor( + method: PreInitMethodCall['method'], + args: PreInitMethodParams, + resolve: PreInitMethodCall['resolve'] = () => {}, + reject: PreInitMethodCall['reject'] = console.error + ) { + this.method = method + this.resolve = resolve + this.reject = reject + this.called = false + + /** + * For specific events, we want to add page context here + */ + const shouldAddPageContext = ( + ['track', 'screen', 'alias', 'group', 'page', 'identify'] as MethodName[] + ).includes(method) + this.args = + shouldAddPageContext && !hasPageContextAsLastArg(args) + ? [...args, getPageContext()] + : args + } } export type PreInitMethodParams = - Parameters + | [...Parameters, PageContext] + | Parameters /** * Infer return type; if return type is promise, unwrap it. @@ -107,6 +138,16 @@ type ReturnTypeUnwrap = Fn extends (...args: any[]) => infer ReturnT type MethodCallMap = Partial> +type SnippetWindowBufferedMethodCall< + MethodName extends PreInitMethodName = PreInitMethodName +> = [MethodName, ...PreInitMethodParams] + +/** + * A list of the method calls before initialization for snippet users + * For example, [["track", "foo", {bar: 123}], ["page"], ["on", "ready", function(){..}] + */ +type SnippetBuffer = SnippetWindowBufferedMethodCall[] + /** * Represents any and all the buffered method calls that occurred before initialization. */ @@ -136,6 +177,30 @@ export class PreInitMethodCallBuffer { this._value = {} as MethodCallMap return this } + + /** + * Fetch the buffered method calls from the window object, + * normalize them, and use them to hydrate the buffer. + * This removes existing buffered calls from the window object. + */ + fetchSnippetWindowBuffer() { + const methodCalls = this._popSnippetWindowBuffer() + this.push(...methodCalls) + } + + private _popSnippetWindowBuffer(): PreInitMethodCall[] { + const wa = window.analytics + if (!Array.isArray(wa)) return [] + const buffered: SnippetBuffer = wa.splice(0, wa.length) + return buffered.map((v) => this._transformSnippetCall(v)) + } + + private _transformSnippetCall([ + methodName, + ...args + ]: SnippetWindowBufferedMethodCall): PreInitMethodCall { + return new PreInitMethodCall(methodName, args as any) + } } /** @@ -157,7 +222,6 @@ export async function callAnalyticsMethod( )(...call.args) if (isThenable(result)) { - // do not defer for non-async methods await result } @@ -176,9 +240,13 @@ export class AnalyticsBuffered { instance?: Analytics ctx?: Context - private _preInitBuffer = new PreInitMethodCallBuffer() + /** + * We're going to assume that page URL + */ + private _preInitBuffer: PreInitMethodCallBuffer private _promise: Promise<[Analytics, Context]> constructor(loader: AnalyticsLoader) { + this._preInitBuffer = new PreInitMethodCallBuffer() this._promise = loader(this._preInitBuffer) this._promise .then(([ajs, ctx]) => { @@ -253,13 +321,9 @@ export class AnalyticsBuffered } return new Promise((resolve, reject) => { - this._preInitBuffer.push({ - method: methodName, - args, - resolve: resolve, - reject: reject, - called: false, - } as PreInitMethodCall) + this._preInitBuffer.push( + new PreInitMethodCall(methodName, args, resolve as any, reject) + ) }) } } @@ -274,13 +338,7 @@ export class AnalyticsBuffered void (this.instance[methodName] as Function)(...args) return this } else { - this._preInitBuffer.push({ - method: methodName, - args, - resolve: () => {}, - reject: console.error, - called: false, - } as PreInitMethodCall) + this._preInitBuffer.push(new PreInitMethodCall(methodName, args)) } return this diff --git a/packages/browser/src/core/buffer/snippet.ts b/packages/browser/src/core/buffer/snippet.ts index 3015dd144..8af7b4a09 100644 --- a/packages/browser/src/core/buffer/snippet.ts +++ b/packages/browser/src/core/buffer/snippet.ts @@ -1,20 +1,10 @@ -import type { - PreInitMethodCall, - PreInitMethodName, - PreInitMethodParams, -} from '.' +import { PreInitMethodCall, PreInitMethodName, PreInitMethodParams } from '.' export function transformSnippetCall([ methodName, ...args ]: SnippetWindowBufferedMethodCall): PreInitMethodCall { - return { - method: methodName, - resolve: () => {}, - reject: console.error, - args, - called: false, - } + return new PreInitMethodCall(methodName, args) } const normalizeSnippetBuffer = (buffer: SnippetBuffer): PreInitMethodCall[] => { diff --git a/packages/browser/src/core/events/__tests__/index.test.ts b/packages/browser/src/core/events/__tests__/index.test.ts index 86e6ecd0d..7bf62b53a 100644 --- a/packages/browser/src/core/events/__tests__/index.test.ts +++ b/packages/browser/src/core/events/__tests__/index.test.ts @@ -1,6 +1,7 @@ import uuid from '@lukeed/uuid' import { range, uniq } from 'lodash' import { EventFactory } from '..' +import { createPageCtx } from '../../../test-helpers/fixtures' import { User } from '../../user' import { SegmentEvent, Options } from '../interfaces' @@ -11,6 +12,11 @@ describe('Event Factory', () => { const shoes = { product: 'shoes', total: '$35', category: 'category' } const shopper = { totalSpent: 100 } + const pageCtxFixture = createPageCtx() + const defaultContext = { + page: pageCtxFixture, + } + beforeEach(() => { user = new User() user.reset() @@ -67,7 +73,7 @@ describe('Event Factory', () => { it('accepts properties', () => { const page = factory.page('category', 'name', shoes) - expect(page.properties).toEqual(shoes) + expect(page.properties).toEqual(expect.objectContaining(shoes)) }) it('ignores category and page if not passed in', () => { @@ -153,7 +159,7 @@ describe('Event Factory', () => { const track = factory.track('Order Completed', shoes, { opt1: true, }) - expect(track.context).toEqual({ opt1: true }) + expect(track.context).toEqual({ opt1: true, ...defaultContext }) }) test('sets context correctly if property arg is undefined', () => { @@ -161,7 +167,10 @@ describe('Event Factory', () => { context: { page: { path: '/custom' } }, }) - expect(track.context).toEqual({ page: { path: '/custom' } }) + expect(track.context?.page).toEqual({ + ...pageCtxFixture, + path: '/custom', + }) }) test('sets integrations', () => { @@ -243,7 +252,11 @@ describe('Event Factory', () => { }, }) - expect(track.context).toEqual({ opt1: true, opt2: '🥝' }) + expect(track.context).toEqual({ + opt1: true, + opt2: '🥝', + ...defaultContext, + }) }) test('should not move known options into `context`', () => { @@ -257,7 +270,11 @@ describe('Event Factory', () => { timestamp: new Date(), }) - expect(track.context).toEqual({ opt1: true, opt2: '🥝' }) + expect(track.context).toEqual({ + opt1: true, + opt2: '🥝', + ...defaultContext, + }) }) test('accepts an anonymous id', () => { @@ -265,7 +282,7 @@ describe('Event Factory', () => { anonymousId: 'anon-1', }) - expect(track.context).toEqual({}) + expect(track.context).toEqual(defaultContext) expect(track.anonymousId).toEqual('anon-1') }) @@ -275,7 +292,7 @@ describe('Event Factory', () => { timestamp, }) - expect(track.context).toEqual({}) + expect(track.context).toEqual(defaultContext) expect(track.timestamp).toEqual(timestamp) }) @@ -306,6 +323,7 @@ describe('Event Factory', () => { name: 'ajs-next', version: '0.1.0', }, + page: pageCtxFixture, }) }) @@ -328,6 +346,7 @@ describe('Event Factory', () => { }, foreignProp: '🇧🇷', innerProp: '👻', + ...defaultContext, }) }) @@ -394,7 +413,7 @@ describe('Event Factory', () => { integrations: { Segment: true }, type: 'track', userId: 'user-id', - context: {}, + context: defaultContext, }) }) }) diff --git a/packages/browser/src/core/events/index.ts b/packages/browser/src/core/events/index.ts index 8af4820f9..aa994a1ea 100644 --- a/packages/browser/src/core/events/index.ts +++ b/packages/browser/src/core/events/index.ts @@ -9,6 +9,8 @@ import { SegmentEvent, } from './interfaces' import md5 from 'spark-md5' +import { getPageContext, PageContext } from '../page' +import { pick } from 'lodash' export * from './interfaces' @@ -23,16 +25,20 @@ export class EventFactory { event: string, properties?: EventProperties, options?: Options, - globalIntegrations?: Integrations + globalIntegrations?: Integrations, + pageCtx?: PageContext ): SegmentEvent { - return this.normalize({ - ...this.baseEvent(), - event, - type: 'track' as const, - properties, - options: { ...options }, - integrations: { ...globalIntegrations }, - }) + return this.normalize( + { + ...this.baseEvent(), + event, + type: 'track' as const, + properties, + options: { ...options }, + integrations: { ...globalIntegrations }, + }, + pageCtx + ) } page( @@ -40,7 +46,8 @@ export class EventFactory { page: string | null, properties?: EventProperties, options?: Options, - globalIntegrations?: Integrations + globalIntegrations?: Integrations, + pageCtx?: PageContext ): SegmentEvent { const event: Partial = { type: 'page' as const, @@ -59,10 +66,13 @@ export class EventFactory { event.name = page } - return this.normalize({ - ...this.baseEvent(), - ...event, - } as SegmentEvent) + return this.normalize( + { + ...this.baseEvent(), + ...event, + } as SegmentEvent, + pageCtx + ) } screen( @@ -70,7 +80,8 @@ export class EventFactory { screen: string | null, properties?: EventProperties, options?: Options, - globalIntegrations?: Integrations + globalIntegrations?: Integrations, + pageCtx?: PageContext ): SegmentEvent { const event: Partial = { type: 'screen' as const, @@ -86,50 +97,61 @@ export class EventFactory { if (screen !== null) { event.name = screen } - - return this.normalize({ - ...this.baseEvent(), - ...event, - } as SegmentEvent) + return this.normalize( + { + ...this.baseEvent(), + ...event, + } as SegmentEvent, + pageCtx + ) } identify( userId: ID, traits?: Traits, options?: Options, - globalIntegrations?: Integrations + globalIntegrations?: Integrations, + pageCtx?: PageContext ): SegmentEvent { - return this.normalize({ - ...this.baseEvent(), - type: 'identify' as const, - userId, - traits, - options: { ...options }, - integrations: { ...globalIntegrations }, - }) + return this.normalize( + { + ...this.baseEvent(), + type: 'identify' as const, + userId, + traits, + options: { ...options }, + integrations: { ...globalIntegrations }, + }, + pageCtx + ) } group( groupId: ID, traits?: Traits, options?: Options, - globalIntegrations?: Integrations + globalIntegrations?: Integrations, + pageCtx?: PageContext ): SegmentEvent { - return this.normalize({ - ...this.baseEvent(), - type: 'group' as const, - traits, - options: { ...options }, - integrations: { ...globalIntegrations }, - groupId, - }) + return this.normalize( + { + ...this.baseEvent(), + type: 'group' as const, + traits, + options: { ...options }, + integrations: { ...globalIntegrations }, + groupId, + }, + pageCtx + ) } alias( to: string, from: string | null, options?: Options, - globalIntegrations?: Integrations + globalIntegrations?: Integrations, + pageCtx?: PageContext ): SegmentEvent { const base: Partial = { userId: to, @@ -149,10 +171,13 @@ export class EventFactory { } as SegmentEvent) } - return this.normalize({ - ...this.baseEvent(), - ...base, - } as SegmentEvent) + return this.normalize( + { + ...this.baseEvent(), + ...base, + } as SegmentEvent, + pageCtx + ) } private baseEvent(): Partial { @@ -174,6 +199,38 @@ export class EventFactory { return base } + private addEventPageContext( + event: SegmentEvent, + pageCtx: PageContext | undefined + ): void { + event.context = event.context || {} + const defaultPageContext = getPageContext() + event.context.page = { + ...defaultPageContext, + ...pageCtx, + ...event.context.page, + } + + if (event.type === 'page') { + // if user does "analytics.page('category', 'name', { url: "foo" })"... use the properties as source of truth + const pageContextFromEventProps = pick( + event.properties, + Object.keys(defaultPageContext) + ) + + event.context.page = { + ...event.context.page, + ...pageContextFromEventProps, + } + + event.properties = { + ...event.context.page, + ...event.properties, + ...(event.name ? { name: event.name } : {}), + } + } + } + /** * Builds the context part of an event based on "foreign" keys that * are provided in the `Options` parameter for an Event @@ -204,7 +261,7 @@ export class EventFactory { return [context, overrides] } - public normalize(event: SegmentEvent): SegmentEvent { + public normalize(event: SegmentEvent, pageCtx?: PageContext): SegmentEvent { // set anonymousId globally if we encounter an override //segment.com/docs/connections/sources/catalog/libraries/website/javascript/identity/#override-the-anonymous-id-using-the-options-object event.options?.anonymousId && @@ -235,21 +292,18 @@ export class EventFactory { const [context, overrides] = this.context(event) const { options, ...rest } = event - const body = { + const newEvent: SegmentEvent = { timestamp: new Date(), ...rest, context, integrations: allIntegrations, ...overrides, } + // NOTE() This is a solution that was implemented a long time ago to prevent some hash collision issue. + newEvent.messageId = 'ajs-next-' + md5.hash(JSON.stringify(event) + uuid()) - const messageId = 'ajs-next-' + md5.hash(JSON.stringify(body) + uuid()) - - const evt: SegmentEvent = { - ...body, - messageId, - } + this.addEventPageContext(newEvent, pageCtx) - return evt + return newEvent } } diff --git a/packages/browser/src/core/page/__tests__/index.test.ts b/packages/browser/src/core/page/__tests__/index.test.ts new file mode 100644 index 000000000..da2cf173b --- /dev/null +++ b/packages/browser/src/core/page/__tests__/index.test.ts @@ -0,0 +1,57 @@ +import { getPageContext, isPageContext, PAGE_CTX_DISCRIMINANT } from '../' + +describe(isPageContext, () => { + it('should return true if object is page context', () => { + expect(isPageContext({})).toBe(false) + expect(isPageContext('')).toBe(false) + expect(isPageContext({ url: 'http://foo.com' })).toBe(false) + expect(isPageContext({ __type: PAGE_CTX_DISCRIMINANT })).toBe(true) + }) +}) + +describe(getPageContext, () => { + const el = document.createElement('link') + el.setAttribute('rel', 'canonical') + + beforeEach(() => { + el.setAttribute('href', '') + document.clear() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('handles no canonical links', () => { + const defs = getPageContext() + expect(defs.url).not.toBeNull() + }) + + it('handles canonical links', () => { + el.setAttribute('href', 'http://www.segment.local') + document.body.appendChild(el) + const defs = getPageContext() + expect(defs.url).toEqual('http://www.segment.local') + }) + + it('handles canonical links with a path', () => { + el.setAttribute('href', 'http://www.segment.local/test') + document.body.appendChild(el) + const defs = getPageContext() + expect(defs.url).toEqual('http://www.segment.local/test') + expect(defs.path).toEqual('/test') + }) + + it('handles canonical links with search params in the url', () => { + el.setAttribute('href', 'http://www.segment.local?test=true') + document.body.appendChild(el) + const defs = getPageContext() + expect(defs.url).toEqual('http://www.segment.local?test=true') + }) + + it('if canonical does not exist, returns fallback', () => { + document.body.appendChild(el) + const defs = getPageContext() + expect(defs.url).toEqual(window.location.href) + }) +}) diff --git a/packages/browser/src/core/page/get-page-context.ts b/packages/browser/src/core/page/get-page-context.ts new file mode 100644 index 000000000..b3dcfb69b --- /dev/null +++ b/packages/browser/src/core/page/get-page-context.ts @@ -0,0 +1,58 @@ +/** + * Represents the PageContext at the moment of event creation. + * + * This "__type" key is helpful because this object is also constructed via the snippet. + * Given that we use a lot of crazt positional arguments, + * distinguishing between regular properties objects and this page object can potentially be difficult. + */ +export interface PageContext { + __type?: typeof PAGE_CTX_DISCRIMINANT + path: string + referrer: string + search: string + title: string + url: string +} +export const PAGE_CTX_DISCRIMINANT = 'page_ctx' + +export function isPageContext(v: unknown): v is PageContext { + return ( + typeof v === 'object' && + v !== null && + '__type' in v && + (v.__type as PageContext['__type']) === PAGE_CTX_DISCRIMINANT + ) +} + +/** + * Get page properties from the browser window/document. + * + */ +export function getPageContext(): PageContext { + // Note: Any changes to this function should be copy+pasted into the @segment/snippet package! + // es5-only syntax + methods + const canonEl = document.querySelector("link[rel='canonical']") + const canonicalPath = canonEl && canonEl.getAttribute('href') + const searchParams = location.search + return { + __type: 'page_ctx', + path: (function () { + if (!canonicalPath) return window.location.pathname + const a = document.createElement('a') + a.href = canonicalPath + return a.pathname[0] === '/' ? a.pathname : '/' + a.pathname + })(), + referrer: document.referrer, + search: searchParams, + title: document.title, + url: (function () { + if (canonicalPath) + return canonicalPath.indexOf('?') > -1 + ? canonicalPath + : canonicalPath + searchParams + const url = window.location.href + const hashIdx = url.indexOf('#') + return hashIdx === -1 ? url : url.slice(0, hashIdx) + })(), + } +} diff --git a/packages/browser/src/core/page/index.ts b/packages/browser/src/core/page/index.ts new file mode 100644 index 000000000..d5b18f4e8 --- /dev/null +++ b/packages/browser/src/core/page/index.ts @@ -0,0 +1 @@ +export * from './get-page-context' diff --git a/packages/browser/src/plugins/page-enrichment/index.ts b/packages/browser/src/plugins/page-enrichment/index.ts deleted file mode 100644 index 6a1410057..000000000 --- a/packages/browser/src/plugins/page-enrichment/index.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { pick } from '../../lib/pick' -import type { Context } from '../../core/context' -import type { Plugin } from '../../core/plugin' - -interface PageDefault { - [key: string]: unknown - path: string - referrer: string - search: string - title: string - url: string -} - -/** - * Get the current page's canonical URL. - */ -function canonical(): string | undefined { - const canon = document.querySelector("link[rel='canonical']") - if (canon) { - return canon.getAttribute('href') || undefined - } -} - -/** - * Return the canonical path for the page. - */ - -function canonicalPath(): string { - const canon = canonical() - if (!canon) { - return window.location.pathname - } - - const a = document.createElement('a') - a.href = canon - const pathname = !a.pathname.startsWith('/') ? '/' + a.pathname : a.pathname - - return pathname -} - -/** - * Return the canonical URL for the page concat the given `search` - * and strip the hash. - */ - -export function canonicalUrl(search = ''): string { - const canon = canonical() - if (canon) { - return canon.includes('?') ? canon : `${canon}${search}` - } - const url = window.location.href - const i = url.indexOf('#') - return i === -1 ? url : url.slice(0, i) -} - -/** - * Return a default `options.context.page` object. - * - * https://segment.com/docs/spec/page/#properties - */ - -export function pageDefaults(): PageDefault { - return { - path: canonicalPath(), - referrer: document.referrer, - search: location.search, - title: document.title, - url: canonicalUrl(location.search), - } -} - -function enrichPageContext(ctx: Context): Context { - const event = ctx.event - event.context = event.context || {} - - const defaultPageContext = pageDefaults() - - const pageContextFromEventProps = - event.properties && pick(event.properties, Object.keys(defaultPageContext)) - - event.context.page = { - ...defaultPageContext, - ...pageContextFromEventProps, - ...event.context.page, - } - - if (event.type === 'page') { - event.properties = { - ...defaultPageContext, - ...event.properties, - ...(event.name ? { name: event.name } : {}), - } - } - - return ctx -} - -export const pageEnrichment: Plugin = { - name: 'Page Enrichment', - version: '0.1.0', - isLoaded: () => true, - load: () => Promise.resolve(), - type: 'before', - page: enrichPageContext, - alias: enrichPageContext, - track: enrichPageContext, - identify: enrichPageContext, - group: enrichPageContext, -} diff --git a/packages/browser/src/plugins/segmentio/__tests__/index.test.ts b/packages/browser/src/plugins/segmentio/__tests__/index.test.ts index 590d593dc..8796cd26d 100644 --- a/packages/browser/src/plugins/segmentio/__tests__/index.test.ts +++ b/packages/browser/src/plugins/segmentio/__tests__/index.test.ts @@ -3,7 +3,6 @@ import unfetch from 'unfetch' import { segmentio, SegmentioSettings } from '..' import { Analytics } from '../../../core/analytics' import { Plugin } from '../../../core/plugin' -import { pageEnrichment } from '../../page-enrichment' import cookie from 'js-cookie' jest.mock('unfetch', () => { @@ -24,7 +23,7 @@ describe('Segment.io', () => { analytics = new Analytics({ writeKey: options.apiKey }) segment = segmentio(analytics, options, {}) - await analytics.register(segment, pageEnrichment) + await analytics.register(segment) window.localStorage.clear() @@ -55,7 +54,7 @@ describe('Segment.io', () => { } const analytics = new Analytics({ writeKey: options.apiKey }) const segment = segmentio(analytics, options, {}) - await analytics.register(segment, pageEnrichment) + await analytics.register(segment) // @ts-ignore test a valid ajsc page call await analytics.page(null, { foo: 'bar' }) diff --git a/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts b/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts index 4bb1b601f..1969c5aaf 100644 --- a/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts +++ b/packages/browser/src/plugins/segmentio/__tests__/retries.test.ts @@ -3,7 +3,6 @@ import { Analytics } from '../../../core/analytics' // @ts-ignore isOffline mocked dependency is accused as unused import { isOffline } from '../../../core/connection' import { Plugin } from '../../../core/plugin' -import { pageEnrichment } from '../../page-enrichment' import { scheduleFlush } from '../schedule-flush' import * as PPQ from '../../../lib/priority-queue/persisted' import * as PQ from '../../../lib/priority-queue' @@ -56,7 +55,7 @@ describe('Segment.io retries', () => { segment = segmentio(analytics, options, {}) - await analytics.register(segment, pageEnrichment) + await analytics.register(segment) }) test(`add events to the queue`, async () => { diff --git a/packages/browser/src/test-helpers/fixtures/create-page-context.ts b/packages/browser/src/test-helpers/fixtures/create-page-context.ts new file mode 100644 index 000000000..eb0047310 --- /dev/null +++ b/packages/browser/src/test-helpers/fixtures/create-page-context.ts @@ -0,0 +1,15 @@ +import { PageContext, PAGE_CTX_DISCRIMINANT } from '../../core/page' + +const pageCtxFixture: PageContext = { + __type: PAGE_CTX_DISCRIMINANT, + path: '/', + referrer: '', + search: '', + title: '', + url: 'http://localhost/', +} + +export const createPageCtx = (ctx: Partial = {}): PageContext => ({ + ...pageCtxFixture, + ...ctx, +}) diff --git a/packages/browser/src/test-helpers/fixtures/index.ts b/packages/browser/src/test-helpers/fixtures/index.ts new file mode 100644 index 000000000..9bc061283 --- /dev/null +++ b/packages/browser/src/test-helpers/fixtures/index.ts @@ -0,0 +1,4 @@ +export * from './create-page-context' +export * from './create-fetch-method' +export * from './classic-destination' +export * from './cdn-settings' diff --git a/packages/config/src/jest/config.js b/packages/config/src/jest/config.js index cb9bc6e80..46f709512 100644 --- a/packages/config/src/jest/config.js +++ b/packages/config/src/jest/config.js @@ -17,6 +17,7 @@ const createJestTSConfig = ({ ...(global.JEST_ROOT_CONFIG ? {} : { displayName: path.basename(process.cwd()) }), + verbose: false, // do not show disabled tests moduleNameMapper: moduleMap, preset: 'ts-jest', modulePathIgnorePatterns: [