diff --git a/.changeset/late-bikes-notice.md b/.changeset/late-bikes-notice.md new file mode 100644 index 000000000..d68684841 --- /dev/null +++ b/.changeset/late-bikes-notice.md @@ -0,0 +1,6 @@ +--- +'@segment/analytics-signals': minor +'@segment/analytics-signals-runtime': minor +--- + +Add anonymousID and timestamp to signals diff --git a/jest.config.js b/jest.config.js index 3a8af5483..f59880664 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,5 +18,6 @@ module.exports = () => '/packages/consent/consent-wrapper-onetrust', '/scripts', '/packages/signals/signals', + '/packages/test-helpers', ], }) diff --git a/packages/signals/signals-integration-tests/src/tests/signals-vanilla/top-level-metadata.test.ts b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/top-level-metadata.test.ts new file mode 100644 index 000000000..f956d3e37 --- /dev/null +++ b/packages/signals/signals-integration-tests/src/tests/signals-vanilla/top-level-metadata.test.ts @@ -0,0 +1,71 @@ +import { test, expect } from '@playwright/test' +import { waitForCondition } from '../../helpers/playwright-utils' +import { IndexPage } from './index-page' + +const basicEdgeFn = ` + // this is a process signal function + const processSignal = (signal) => { + if (signal.type === 'interaction') { + analytics.track('hello', { myAnonId: signal.anonymousId, myTimestamp: signal.timestamp }) + } + } +` + +let indexPage: IndexPage + +const isoDateRegEx = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + +test.beforeEach(async ({ page }) => { + indexPage = await new IndexPage().loadAndWait(page, basicEdgeFn) +}) + +test('Signals should have anonymousId and timestamp at top level', async () => { + await indexPage.network.mockTestRoute() + await indexPage.network.makeFetchCall() + await Promise.all([ + indexPage.clickButton(), + indexPage.makeAnalyticsPageCall(), + indexPage.waitForSignalsApiFlush(), + indexPage.waitForTrackingApiFlush(), + ]) + + const types = [ + 'network', + 'interaction', + 'instrumentation', + 'navigation', + ] as const + + const evs = types.map((type) => ({ + type, + networkCalls: indexPage.signalsAPI.getEvents(type), + })) + + evs.forEach((events) => { + if (!events.networkCalls.length) { + throw new Error(`No events found for type ${events.type}`) + } + events.networkCalls.forEach((event) => { + const expected = { + anonymousId: event.anonymousId, + timestamp: expect.stringMatching(isoDateRegEx), + type: event.properties!.type, + } + expect(event.properties).toMatchObject(expected) + }) + }) + + const getCreatedEvent = () => + indexPage.trackingAPI + .getEvents() + .find((el) => el.type === 'track' && el.event === 'hello') + + await waitForCondition(() => !!getCreatedEvent(), { + errorMessage: 'No track events found, should have an event', + }) + const event = getCreatedEvent()! + expect(event.properties).toEqual({ + myAnonId: event.anonymousId, + myTimestamp: expect.stringMatching(isoDateRegEx), + }) +}) diff --git a/packages/signals/signals-runtime/package.json b/packages/signals/signals-runtime/package.json index 083b7659f..30ce8481e 100644 --- a/packages/signals/signals-runtime/package.json +++ b/packages/signals/signals-runtime/package.json @@ -26,7 +26,7 @@ "build:cjs": "yarn tsc -p tsconfig.build.json --outDir ./dist/cjs --module commonjs", "build:global": "node build-signals-runtime-global.js", "assert-generated": "bash scripts/assert-generated.sh", - "watch": "rm -rf dist/esm && yarn build:esm --watch", + "watch": "rm -rf dist/esm && yarn build:esm && yarn build:esm --watch", "watch:test": "yarn test --watch", "tsc": "yarn run -T tsc", "eslint": "yarn run -T eslint", diff --git a/packages/signals/signals-runtime/src/shared/shared-types.ts b/packages/signals/signals-runtime/src/shared/shared-types.ts index afdf57786..fb863fc2f 100644 --- a/packages/signals/signals-runtime/src/shared/shared-types.ts +++ b/packages/signals/signals-runtime/src/shared/shared-types.ts @@ -1,5 +1,9 @@ +export type ID = string | null | undefined + export interface BaseSignal { type: string + anonymousId: ID + timestamp: string } export type SignalOfType< diff --git a/packages/signals/signals-runtime/src/test-helpers/mocks/mock-signal-types-web.ts b/packages/signals/signals-runtime/src/test-helpers/mocks/mock-signal-types-web.ts index 80b7161e4..cac623b45 100644 --- a/packages/signals/signals-runtime/src/test-helpers/mocks/mock-signal-types-web.ts +++ b/packages/signals/signals-runtime/src/test-helpers/mocks/mock-signal-types-web.ts @@ -1,3 +1,4 @@ +import { BaseSignal } from '../../shared/shared-types' import { InteractionSignal, NavigationSignal, @@ -8,6 +9,13 @@ import { } from '../../web/web-signals-types' // Mock data for testing +type DefaultProps = Pick + +const baseSignalProps: DefaultProps = { + anonymousId: '123', + timestamp: '2020-01-01T00:00:00.000Z', +} + export const mockPageData: PageData = { url: 'https://www.segment.com/docs/connections/sources/catalog/libraries/website/javascript/', path: '/docs/connections/sources/catalog/libraries/website/javascript/', @@ -29,7 +37,7 @@ export const mockInteractionSignal: InteractionSignal = { attributes: { id: 'button1', class: 'btn-primary' }, }, }, - metadata: { timestamp: Date.now() }, + ...baseSignalProps, } export const mockNavigationSignal: NavigationSignal = { @@ -41,7 +49,7 @@ export const mockNavigationSignal: NavigationSignal = { hash: '#section1', prevUrl: 'https://example.com/home', }, - metadata: { timestamp: Date.now() }, + ...baseSignalProps, } export const mockInstrumentationSignal: InstrumentationSignal = { @@ -50,7 +58,7 @@ export const mockInstrumentationSignal: InstrumentationSignal = { page: mockPageData, rawEvent: { type: 'customEvent', detail: 'example' }, }, - metadata: { timestamp: Date.now() }, + ...baseSignalProps, } export const mockNetworkSignal: NetworkSignal = { @@ -63,7 +71,7 @@ export const mockNetworkSignal: NetworkSignal = { method: 'GET', data: { key: 'value' }, }, - metadata: { timestamp: Date.now() }, + ...baseSignalProps, } export const mockUserDefinedSignal: UserDefinedSignal = { @@ -72,5 +80,5 @@ export const mockUserDefinedSignal: UserDefinedSignal = { page: mockPageData, customField: 'customValue', }, - metadata: { timestamp: Date.now() }, + ...baseSignalProps, } diff --git a/packages/signals/signals/jest.config.js b/packages/signals/signals/jest.config.js index 824e08ca5..82f32aa3f 100644 --- a/packages/signals/signals/jest.config.js +++ b/packages/signals/signals/jest.config.js @@ -2,5 +2,5 @@ const { createJestTSConfig } = require('@internal/config') module.exports = createJestTSConfig(__dirname, { testEnvironment: 'jsdom', - setupFilesAfterEnv: ['./jest.setup.js'], + setupFilesAfterEnv: ['./jest.setup.ts'], }) diff --git a/packages/signals/signals/jest.setup.js b/packages/signals/signals/jest.setup.js deleted file mode 100644 index f8458c124..000000000 --- a/packages/signals/signals/jest.setup.js +++ /dev/null @@ -1,3 +0,0 @@ -/* eslint-disable no-undef */ -require('fake-indexeddb/auto') -globalThis.structuredClone = (v) => JSON.parse(JSON.stringify(v)) diff --git a/packages/signals/signals/jest.setup.ts b/packages/signals/signals/jest.setup.ts new file mode 100644 index 000000000..edbfd7c3f --- /dev/null +++ b/packages/signals/signals/jest.setup.ts @@ -0,0 +1,6 @@ +/* eslint-disable no-undef */ +import './src/test-helpers/jest-extended' +import { JestSerializers } from '@internal/test-helpers' +import 'fake-indexeddb/auto' +globalThis.structuredClone = (v: any) => JSON.parse(JSON.stringify(v)) +expect.addSnapshotSerializer(JestSerializers.jestSnapshotSerializerTimestamp) diff --git a/packages/signals/signals/package.json b/packages/signals/signals/package.json index 06abbe0bd..9b94ce82c 100644 --- a/packages/signals/signals/package.json +++ b/packages/signals/signals/package.json @@ -25,6 +25,7 @@ ], "scripts": { ".": "yarn run -T turbo run --filter=@segment/analytics-signals...", + "..": "yarn run -T turbo run --filter=...@segment/analytics-signals...", "test": "yarn jest", "lint": "yarn concurrently 'yarn:eslint .' 'yarn:tsc --noEmit'", "build": "rm -rf dist && yarn concurrently 'yarn:build:*'", diff --git a/packages/signals/signals/src/core/analytics-service/__tests__/analytics-service.test.ts b/packages/signals/signals/src/core/analytics-service/__tests__/analytics-service.test.ts index f72e2056f..dfc460089 100644 --- a/packages/signals/signals/src/core/analytics-service/__tests__/analytics-service.test.ts +++ b/packages/signals/signals/src/core/analytics-service/__tests__/analytics-service.test.ts @@ -42,6 +42,7 @@ describe(AnalyticsService, () => { delete call.data.page expect(call).toMatchInlineSnapshot(` { + "anonymousId": "", "data": { "rawEvent": { "context": { @@ -50,6 +51,7 @@ describe(AnalyticsService, () => { "type": "track", }, }, + "timestamp": , "type": "instrumentation", } `) diff --git a/packages/signals/signals/src/core/middleware/signals-ingest/__tests__/client.test.ts b/packages/signals/signals/src/core/middleware/signals-ingest/__tests__/client.test.ts index aab8c3c0e..8b7a37584 100644 --- a/packages/signals/signals/src/core/middleware/signals-ingest/__tests__/client.test.ts +++ b/packages/signals/signals/src/core/middleware/signals-ingest/__tests__/client.test.ts @@ -1,3 +1,4 @@ +import { ISO_TIMESTAMP_REGEX } from '@internal/test-helpers' import { createSuccess } from '@segment/analytics-next/src/test-helpers/factories' import unfetch from 'unfetch' import { getPageData } from '../../../../lib/page-data' @@ -29,7 +30,9 @@ describe(SignalsIngestClient, () => { const ctx = await client.send(signal) expect(ctx!.event.type).toEqual('track') - expect(ctx!.event.properties).toEqual({ + expect(ctx!.event.properties).toMatchObject({ + anonymousId: expect.any(String), + timestamp: expect.stringMatching(ISO_TIMESTAMP_REGEX), type: 'instrumentation', index: 0, data: { @@ -53,26 +56,36 @@ describe(SignalsIngestClient, () => { }) const ctx = await client.send(signal) - expect(ctx!.event.type).toEqual('track') - expect(ctx!.event.properties!.type).toBe('network') - expect(ctx!.event.properties!.data).toMatchInlineSnapshot(` + expect(ctx!.event.properties!).toMatchInlineSnapshot(` { - "action": "request", - "contentType": "application/json", + "anonymousId": "", "data": { - "hello": "XXX", + "action": "request", + "contentType": "application/json", + "data": { + "hello": "XXX", + }, + "method": "POST", + "page": { + "hash": "", + "hostname": "localhost", + "path": "/", + "referrer": "", + "search": "", + "title": "", + "url": "http://localhost/", + }, + "url": "http://foo.com", }, - "method": "POST", - "page": { - "hash": "", - "hostname": "localhost", - "path": "/", - "referrer": "", - "search": "", - "title": "", - "url": "http://localhost/", + "index": 0, + "metadata": { + "filters": { + "allowed": [], + "disallowed": [], + }, }, - "url": "http://foo.com", + "timestamp": , + "type": "network", } `) }) diff --git a/packages/signals/signals/src/core/middleware/signals-ingest/__tests__/redact.test.ts b/packages/signals/signals/src/core/middleware/signals-ingest/__tests__/redact.test.ts index 7b928b403..93b3c5e18 100644 --- a/packages/signals/signals/src/core/middleware/signals-ingest/__tests__/redact.test.ts +++ b/packages/signals/signals/src/core/middleware/signals-ingest/__tests__/redact.test.ts @@ -93,7 +93,7 @@ describe(redactSignalData, () => { listener: 'onchange', target: createMockTarget({ value: 'XXX', formData: { password: 'XXX' } }), }) - expect(redactSignalData(signal)).toEqual(expected) + expect(redactSignalData(signal)).toMatchSignal(expected) }) it('should redact attributes in change and in target if the listener is "mutation"', () => { @@ -117,7 +117,7 @@ describe(redactSignalData, () => { innerText: 'XXX', }), }) - expect(redactSignalData(signal)).toEqual(expected) + expect(redactSignalData(signal)).toMatchSignal(expected) }) it('should redact the textContent and innerText in the "target" property if the listener is "contenteditable"', () => { @@ -133,7 +133,7 @@ describe(redactSignalData, () => { change: { textContent: 'XXX' }, target: createMockTarget({ textContent: 'XXX', innerText: 'XXX' }), }) - expect(redactSignalData(signal)).toEqual(expected) + expect(redactSignalData(signal)).toMatchSignal(expected) }) it('should redact the values in the "data" property if the type is "network"', () => { @@ -157,7 +157,7 @@ describe(redactSignalData, () => { }, metadataFixture ) - expect(redactSignalData(signal)).toEqual(expected) + expect(redactSignalData(signal)).toMatchSignal(expected) }) it('should not mutate the original signal object', () => { diff --git a/packages/signals/signals/src/core/middleware/signals-ingest/signals-ingest-client.ts b/packages/signals/signals/src/core/middleware/signals-ingest/signals-ingest-client.ts index 74c00b512..ae35aafc8 100644 --- a/packages/signals/signals/src/core/middleware/signals-ingest/signals-ingest-client.ts +++ b/packages/signals/signals/src/core/middleware/signals-ingest/signals-ingest-client.ts @@ -85,8 +85,7 @@ export class SignalsIngestClient { return analytics.track(MAGIC_EVENT_NAME, { index: this.index++, - type: signal.type, - data: cleanSignal.data, + ...cleanSignal, }) } diff --git a/packages/signals/signals/src/core/middleware/user-info/index.ts b/packages/signals/signals/src/core/middleware/user-info/index.ts new file mode 100644 index 000000000..61b0403ec --- /dev/null +++ b/packages/signals/signals/src/core/middleware/user-info/index.ts @@ -0,0 +1,17 @@ +import { Signal } from '@segment/analytics-signals-runtime' +import { UserInfo } from '../../../types' +import { SignalsMiddleware, SignalsMiddlewareContext } from '../../emitter' + +export class UserInfoMiddleware implements SignalsMiddleware { + user!: UserInfo + + load(ctx: SignalsMiddlewareContext) { + this.user = ctx.analyticsInstance.user() + } + + process(signal: Signal): Signal { + // anonymousId should always exist here unless the user is explicitly setting it to null + signal.anonymousId = this.user.anonymousId() + return signal + } +} diff --git a/packages/signals/signals/src/core/signal-generators/dom-gen/__tests__/change-gen.test.ts b/packages/signals/signals/src/core/signal-generators/dom-gen/__tests__/change-gen.test.ts index 4b5043d4d..3e777fb7f 100644 --- a/packages/signals/signals/src/core/signal-generators/dom-gen/__tests__/change-gen.test.ts +++ b/packages/signals/signals/src/core/signal-generators/dom-gen/__tests__/change-gen.test.ts @@ -1,8 +1,7 @@ -import { createInteractionSignal } from '../../../../types/factories' import { SignalEmitter } from '../../../emitter' import { OnChangeGenerator } from '../change-gen' -describe('OnChangeGenerator', () => { +describe(OnChangeGenerator, () => { let onChangeGenerator: OnChangeGenerator let emitter: SignalEmitter let unregister: () => void @@ -28,14 +27,49 @@ describe('OnChangeGenerator', () => { unregister = onChangeGenerator.register(emitter) document.dispatchEvent(event) - expect(emitSpy).toHaveBeenCalledWith( - createInteractionSignal({ - eventType: 'change', - listener: 'onchange', - target: expect.any(Object), - change: { value: 'new value' }, - }) - ) + expect(emitSpy.mock.calls.length).toBe(1) + expect(emitSpy.mock.calls[0][0]).toMatchInlineSnapshot(` + { + "anonymousId": "", + "data": { + "change": { + "value": "new value", + }, + "eventType": "change", + "listener": "onchange", + "page": { + "hash": "", + "hostname": "localhost", + "path": "/", + "referrer": "", + "search": "", + "title": "", + "url": "http://localhost/", + }, + "target": { + "attributes": { + "type": "text", + }, + "checked": false, + "classList": [], + "describedBy": undefined, + "id": "", + "innerText": undefined, + "label": undefined, + "labels": [], + "name": "", + "nodeName": "INPUT", + "tagName": "INPUT", + "textContent": "", + "title": "", + "type": "text", + "value": "new value", + }, + }, + "timestamp": , + "type": "interaction", + } + `) }) it('should not emit a signal for ignored elements', () => { @@ -85,15 +119,58 @@ describe('OnChangeGenerator', () => { unregister = onChangeGenerator.register(emitter) document.dispatchEvent(event) - expect(emitSpy).toHaveBeenCalledWith( - createInteractionSignal({ - eventType: 'change', - listener: 'onchange', - target: expect.any(Object), - change: { - selectedOptions: [{ value: 'value1', label: 'label1' }], + expect(emitSpy.mock.lastCall).toMatchInlineSnapshot(` + [ + { + "anonymousId": "", + "data": { + "change": { + "selectedOptions": [ + { + "label": "label1", + "value": "value1", + }, + ], + }, + "eventType": "change", + "listener": "onchange", + "page": { + "hash": "", + "hostname": "localhost", + "path": "/", + "referrer": "", + "search": "", + "title": "", + "url": "http://localhost/", + }, + "target": { + "attributes": {}, + "classList": [], + "describedBy": undefined, + "id": "", + "innerText": undefined, + "label": undefined, + "labels": [], + "name": "", + "nodeName": "SELECT", + "selectedIndex": 0, + "selectedOptions": [ + { + "label": "label1", + "value": "value1", + }, + ], + "tagName": "SELECT", + "textContent": "", + "title": "", + "type": "select-one", + "value": "value1", + }, + }, + "timestamp": , + "type": "interaction", }, - }) - ) + ] + `) }) }) diff --git a/packages/signals/signals/src/core/signal-generators/network-gen/__tests__/network-generator.test.ts b/packages/signals/signals/src/core/signal-generators/network-gen/__tests__/network-generator.test.ts index 32dfee45a..f9f3acad0 100644 --- a/packages/signals/signals/src/core/signal-generators/network-gen/__tests__/network-generator.test.ts +++ b/packages/signals/signals/src/core/signal-generators/network-gen/__tests__/network-generator.test.ts @@ -97,6 +97,7 @@ describe(NetworkGenerator, () => { expect(first[0]).toMatchInlineSnapshot(` { + "anonymousId": "", "data": { "action": "request", "contentType": "application/json", @@ -121,12 +122,14 @@ describe(NetworkGenerator, () => { "disallowed": [], }, }, + "timestamp": , "type": "network", } `) expect(second[0]).toMatchInlineSnapshot(` { + "anonymousId": "", "data": { "action": "response", "contentType": "application/json", @@ -152,6 +155,7 @@ describe(NetworkGenerator, () => { "disallowed": [], }, }, + "timestamp": , "type": "network", } `) diff --git a/packages/signals/signals/src/core/signals/settings.ts b/packages/signals/signals/src/core/signals/settings.ts index 7f4c3a4f6..a77afdae7 100644 --- a/packages/signals/signals/src/core/signals/settings.ts +++ b/packages/signals/signals/src/core/signals/settings.ts @@ -27,6 +27,7 @@ export type SignalsSettingsConfig = Pick< | 'mutationGenObservedTags' | 'mutationGenPollInterval' | 'mutationGenObservedAttributes' + | 'debug' > & { signalStorage?: SignalPersistentStorage processSignal?: string diff --git a/packages/signals/signals/src/core/signals/signals.ts b/packages/signals/signals/src/core/signals/signals.ts index 67060c229..4bda73075 100644 --- a/packages/signals/signals/src/core/signals/signals.ts +++ b/packages/signals/signals/src/core/signals/signals.ts @@ -16,6 +16,7 @@ import { LogLevelOptions } from '../debug-mode' import { SignalsIngestSubscriber } from '../middleware/signals-ingest' import { SignalsEventProcessorSubscriber } from '../middleware/event-processor' import { NetworkSignalsFilterMiddleware } from '../middleware/network-signals-filter/network-signals-filter' +import { UserInfoMiddleware } from '../middleware/user-info' interface ISignals { start(analytics: AnyAnalytics): Promise @@ -37,6 +38,9 @@ export class Signals implements ISignals { private globalSettings: SignalGlobalSettings constructor(settingsConfig: SignalsSettingsConfig = {}) { this.globalSettings = new SignalGlobalSettings(settingsConfig) + if (settingsConfig.debug) { + this.debug() + } this.buffer = getSignalBuffer(this.globalSettings.signalBuffer) this.signalEmitter = this.getSignalEmitter(settingsConfig.middleware) @@ -123,6 +127,7 @@ export class Signals implements ISignals { // we initialize the emitter here so that registerGenerator can be called before start return new SignalEmitter() .addMiddleware( + new UserInfoMiddleware(), new NetworkSignalsFilterMiddleware(), ...(middleware || []) ) diff --git a/packages/signals/signals/src/plugin/__tests__/signals-plugin.test.ts b/packages/signals/signals/src/plugin/__tests__/signals-plugin.test.ts index e28d336b7..72e580999 100644 --- a/packages/signals/signals/src/plugin/__tests__/signals-plugin.test.ts +++ b/packages/signals/signals/src/plugin/__tests__/signals-plugin.test.ts @@ -46,7 +46,7 @@ describe(SignalsPlugin, () => { const userDefinedData = { foo: 'bar' } plugin.addSignal(userDefinedData) expect(emitterSpy).toHaveBeenCalledTimes(1) - expect(emitterSpy.mock.calls[0][0]).toEqual( + expect(emitterSpy.mock.calls[0][0]).toMatchSignal( createUserDefinedSignal(userDefinedData) ) }) diff --git a/packages/signals/signals/src/plugin/signals-plugin.ts b/packages/signals/signals/src/plugin/signals-plugin.ts index 946a70a9f..6d5123300 100644 --- a/packages/signals/signals/src/plugin/signals-plugin.ts +++ b/packages/signals/signals/src/plugin/signals-plugin.ts @@ -29,18 +29,12 @@ export class SignalsPlugin implements Plugin, SignalsAugmentedFunctionality { public signals: Signals constructor(settings: SignalsPluginSettingsConfig = {}) { assertBrowserEnv() - // assign to window for debugging purposes - Object.assign(window, { SegmentSignalsPlugin: this }) - - if (settings.enableDebugLogging) { - logger.enableLogging('debug') - } - logger.debug(`SignalsPlugin v${version} initializing`, { - settings, - }) + // assign to window.SegmentSignalsPlugin for debugging purposes (e.g window.SegmentSignalsPlugin.debug()) + Object.assign(window, { SegmentSignalsPlugin: this }) this.signals = new Signals({ + debug: settings.debug, disableSignalsRedaction: settings.disableSignalsRedaction, enableSignalsIngestion: settings.enableSignalsIngestion, flushAt: settings.flushAt, @@ -59,6 +53,10 @@ export class SignalsPlugin implements Plugin, SignalsAugmentedFunctionality { signalStorageType: settings.signalStorageType, middleware: settings.middleware, }) + + logger.debug(`SignalsPlugin v${version} initializing`, { + settings, + }) } isLoaded() { @@ -91,7 +89,8 @@ export class SignalsPlugin implements Plugin, SignalsAugmentedFunctionality { /** * Enable redaction and disable ingestion of signals. Also, logs signals to the console. */ - debug(...args: Parameters): void { + debug(...args: Parameters): this { this.signals.debug(...args) + return this } } diff --git a/packages/signals/signals/src/test-helpers/jest-extended.ts b/packages/signals/signals/src/test-helpers/jest-extended.ts new file mode 100644 index 000000000..0356cf96c --- /dev/null +++ b/packages/signals/signals/src/test-helpers/jest-extended.ts @@ -0,0 +1,47 @@ +import { Signal } from '@segment/analytics-signals-runtime' + +export function toMatchSignal( + this: jest.MatcherContext, + received: Signal, + expected: Signal +) { + const cleanSignal = (signal: Signal) => { + const { timestamp, ...rest } = signal + return rest + } + + const pass = this.equals(cleanSignal(received), cleanSignal(expected)) + if (pass) { + return { + message: () => + `expected ${this.utils.printReceived( + received + )} not to match ${this.utils.printExpected(expected)}`, + pass: true, + } + } else { + return { + message: () => + `expected ${this.utils.printReceived( + received + )} to match ${this.utils.printExpected(expected)}`, + pass: false, + } + } +} + +/*** + * Add special matcher to compare signals + * usage: + * expect(signal).toMatchSignal(expectedSignal) + */ +expect.extend({ toMatchSignal }) + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface Matchers { + toMatchSignal(expected: Signal): R + } + } +} diff --git a/packages/signals/signals/src/test-helpers/mocks/analytics-mock.ts b/packages/signals/signals/src/test-helpers/mocks/analytics-mock.ts index 302b05641..1d4b39f05 100644 --- a/packages/signals/signals/src/test-helpers/mocks/analytics-mock.ts +++ b/packages/signals/signals/src/test-helpers/mocks/analytics-mock.ts @@ -26,4 +26,8 @@ export const analyticsMock: jest.Mocked = { addSourceMiddleware: jest.fn(), reset: jest.fn(), on: jest.fn(), + user: jest.fn().mockReturnValue({ + id: jest.fn(), + anonymousId: jest.fn(), + }), } diff --git a/packages/signals/signals/src/types/__tests__/create-network-signal.test.ts b/packages/signals/signals/src/types/__tests__/create-network-signal.test.ts index 150020d2d..2245eee62 100644 --- a/packages/signals/signals/src/types/__tests__/create-network-signal.test.ts +++ b/packages/signals/signals/src/types/__tests__/create-network-signal.test.ts @@ -16,7 +16,6 @@ describe(createNetworkSignal, () => { disallowed: ['disallowed1', 'disallowed2'], }, } - it('should create a network signal for a request', () => { const data: NetworkData = { action: 'request', @@ -30,6 +29,7 @@ describe(createNetworkSignal, () => { expect(signal).toMatchInlineSnapshot(` { + "anonymousId": "", "data": { "action": "request", "contentType": "application/json", @@ -60,6 +60,7 @@ describe(createNetworkSignal, () => { ], }, }, + "timestamp": , "type": "network", } `) @@ -80,6 +81,7 @@ describe(createNetworkSignal, () => { expect(signal).toMatchInlineSnapshot(` { + "anonymousId": "", "data": { "action": "response", "contentType": "application/json", @@ -111,6 +113,7 @@ describe(createNetworkSignal, () => { ], }, }, + "timestamp": , "type": "network", } `) diff --git a/packages/signals/signals/src/types/analytics-api.ts b/packages/signals/signals/src/types/analytics-api.ts index a0dc168fb..898660209 100644 --- a/packages/signals/signals/src/types/analytics-api.ts +++ b/packages/signals/signals/src/types/analytics-api.ts @@ -50,6 +50,13 @@ export interface DestinationMiddlewareParams { integration: string } +export type ID = string | null | undefined + +export interface UserInfo { + id(): ID + anonymousId(): ID +} + export interface AnyAnalytics { settings: { cdnSettings: CDNSettings @@ -67,6 +74,7 @@ export interface AnyAnalytics { alias(...args: any[]): void screen(...args: any[]): void reset(): void + user(): UserInfo on(name: 'reset', fn: (...args: any[]) => void): void } diff --git a/packages/signals/signals/src/types/factories.ts b/packages/signals/signals/src/types/factories.ts index 5abd4e1fd..4e6be2d70 100644 --- a/packages/signals/signals/src/types/factories.ts +++ b/packages/signals/signals/src/types/factories.ts @@ -1,7 +1,4 @@ import { - InstrumentationSignal, - InteractionData, - InteractionSignal, NavigationData, NavigationSignal, UserDefinedSignalData, @@ -9,60 +6,65 @@ import { NetworkData, NetworkSignalMetadata, NetworkSignal, + SignalTypes, + Signal, + SignalOfType, + InstrumentationSignal, + InteractionData, + InteractionSignal, SegmentEvent, } from '@segment/analytics-signals-runtime' import { normalizeUrl } from '../lib/normalize-url' import { getPageData } from '../lib/page-data' +type BaseData = Omit< + SignalOfType['data'], + 'page' +> + /** - * Factories + * Base Signal Factory */ -export const createInstrumentationSignal = ( - rawEvent: SegmentEvent -): InstrumentationSignal => { +const createBaseSignal = < + Type extends SignalTypes, + Data extends BaseData +>( + type: Type, + data: Data +) => { return { - type: 'instrumentation', + timestamp: new Date().toISOString(), + anonymousId: '', // to be set by a middleware (that runs once analytics is instantiated) + type, data: { + ...data, page: getPageData(), - rawEvent, }, } } +export const createInstrumentationSignal = ( + rawEvent: SegmentEvent +): InstrumentationSignal => { + return createBaseSignal('instrumentation', { rawEvent }) +} + export const createInteractionSignal = ( data: InteractionData ): InteractionSignal => { - return { - type: 'interaction', - data: { - ...data, - page: getPageData(), - }, - } + return createBaseSignal('interaction', data) } export const createNavigationSignal = ( data: NavigationData ): NavigationSignal => { - return { - type: 'navigation', - data: { - ...data, - page: getPageData(), - }, - } + return createBaseSignal('navigation', data) } export const createUserDefinedSignal = ( data: UserDefinedSignalData ): UserDefinedSignal => { - return { - type: 'userDefined', - data: { - ...data, - page: getPageData(), - }, - } + return createBaseSignal('userDefined', data) } export const createNetworkSignal = ( @@ -70,12 +72,10 @@ export const createNetworkSignal = ( metadata?: NetworkSignalMetadata ): NetworkSignal => { return { - type: 'network', - data: { + ...createBaseSignal('network', { ...data, url: normalizeUrl(data.url), - page: getPageData(), - }, + }), metadata: metadata ?? { filters: { allowed: [], diff --git a/packages/signals/signals/src/types/settings.ts b/packages/signals/signals/src/types/settings.ts index aca4edd7d..7c27fc198 100644 --- a/packages/signals/signals/src/types/settings.ts +++ b/packages/signals/signals/src/types/settings.ts @@ -13,10 +13,11 @@ export interface SignalsPluginSettingsConfig { processSignal?: string | ProcessSignal /** - * Add console debug logging + * Enable debug mode. Same as ?segment_signals_debug=true in the URL + * This sets the log level to 'info', disables redaction, and enables signals ingestion (sending signals to segment) * @default false */ - enableDebugLogging?: boolean + debug?: boolean /** * Disable redaction of signals diff --git a/packages/test-helpers/package.json b/packages/test-helpers/package.json index fb8f8daf9..fda9c2152 100644 --- a/packages/test-helpers/package.json +++ b/packages/test-helpers/package.json @@ -12,7 +12,6 @@ "build:cjs": "yarn tsc -p tsconfig.build.json --outDir ./dist/cjs --module commonjs", "build:esm": "yarn tsc -p tsconfig.build.json", "tsc": "yarn run -T tsc", - "watch": "yarn build:esm --watch", "eslint": "yarn run -T eslint", "concurrently": "yarn run -T concurrently" }, diff --git a/packages/test-helpers/src/constants/index.ts b/packages/test-helpers/src/constants/index.ts new file mode 100644 index 000000000..9fd1dfc5e --- /dev/null +++ b/packages/test-helpers/src/constants/index.ts @@ -0,0 +1,7 @@ +/** + * Matches an ISO 8601 timestamp string. + * @example + * expect('2022-01-01T00:00:00.000Z').toEqual(expect.any(ISO_TIMESTAMP_REGEX)) + */ +export const ISO_TIMESTAMP_REGEX = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ diff --git a/packages/test-helpers/src/index.ts b/packages/test-helpers/src/index.ts index 193cb6db0..ceac9bebe 100644 --- a/packages/test-helpers/src/index.ts +++ b/packages/test-helpers/src/index.ts @@ -1,2 +1,4 @@ export * from './utils' export * from './analytics' +export * from './constants' +export * as JestSerializers from './jest/serializers' diff --git a/packages/test-helpers/src/jest/serializers/index.ts b/packages/test-helpers/src/jest/serializers/index.ts new file mode 100644 index 000000000..dda1c5b01 --- /dev/null +++ b/packages/test-helpers/src/jest/serializers/index.ts @@ -0,0 +1 @@ +export * from './timestamp' diff --git a/packages/test-helpers/src/jest/serializers/timestamp.ts b/packages/test-helpers/src/jest/serializers/timestamp.ts new file mode 100644 index 000000000..e5b878ddc --- /dev/null +++ b/packages/test-helpers/src/jest/serializers/timestamp.ts @@ -0,0 +1,13 @@ +import { ISO_TIMESTAMP_REGEX } from '../../constants' + +/** + * Jest snapshot serializer for ISO 8601 timestamp strings. + */ +export const jestSnapshotSerializerTimestamp: jest.SnapshotSerializerPlugin = { + test(value: any) { + return typeof value === 'string' && ISO_TIMESTAMP_REGEX.test(value) + }, + print() { + return '' + }, +}