diff --git a/package-lock.json b/package-lock.json index 861642ae..545dcc99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@croct/content": "^1.1.0", "@croct/json": "^2.1.0", - "@croct/sdk": "^0.22.4", + "@croct/sdk": "^0.23.0", "tslib": "^2.7.0" }, "devDependencies": { @@ -667,9 +667,9 @@ } }, "node_modules/@croct/sdk": { - "version": "0.22.4", - "resolved": "https://registry.npmjs.org/@croct/sdk/-/sdk-0.22.4.tgz", - "integrity": "sha512-X/Hr0O/FhPPQbpeH+jVWh3Z6dQDCC/2Vyc1g9hipq6SZ4jsy0ZZcFM2OAciLzBrUYhpSudRFufmSPxcz0H447g==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@croct/sdk/-/sdk-0.23.0.tgz", + "integrity": "sha512-mOetzxzOWzZptw7/OMAOXA2oKvsIJ7HTeyRs7TMa1OP111acH14+hc1SwVqXXo6mX5gWkTT59O5/OcbvEkoboQ==", "license": "MIT", "dependencies": { "@croct/content-model": "^0.21.1", diff --git a/package.json b/package.json index 9361cb19..3959045d 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "dependencies": { "@croct/content": "^1.1.0", "@croct/json": "^2.1.0", - "@croct/sdk": "^0.22.4", + "@croct/sdk": "^0.23.0", "tslib": "^2.7.0" }, "devDependencies": { diff --git a/src/api/index.ts b/src/api/index.ts index bb73cd91..a2eb9226 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,2 +1,3 @@ export * from './evaluate'; export * from './fetchContent'; +export * from './track'; diff --git a/src/api/track.ts b/src/api/track.ts new file mode 100644 index 00000000..3f4cedcd --- /dev/null +++ b/src/api/track.ts @@ -0,0 +1,81 @@ +import type {EventData, EventType, PublishOptions, PublishRequest} from '@croct/sdk/eventPublisher'; +import {EventPublisher} from '@croct/sdk/eventPublisher'; +import type {ApiKey} from '@croct/sdk/apiKey'; +import type {Logger} from '@croct/sdk/logging'; +import {formatCause} from '@croct/sdk/error'; + +/** + * The payload as exposed to callers, which may use different names than the API. + */ +type PublicPayload

= P extends {externalUserId: infer U} + ? Omit & {userId: U} + : P; + +export type TrackedEvent = { + // Distributes over the event types so that the type and payload of each event stay correlated + [K in T]: { + type: K, + payload: PublicPayload['payload']>, + } +}[T] & Omit; + +type AuthOptions = ServerSideAuthOptions | ClientSideAuthOptions; + +type ServerSideAuthOptions = { + apiKey: string | ApiKey, + appId?: never, +}; + +type ClientSideAuthOptions = { + appId: string, + apiKey?: never, +}; + +export type TrackOptions = PublishOptions & AuthOptions & { + baseEndpointUrl?: string, + logger?: Logger, +}; + +export function track(event: TrackedEvent, options: TrackOptions): Promise; + +export function track(event: TrackedEvent, options: TrackOptions): Promise { + const {apiKey, appId, baseEndpointUrl, logger, ...publishOptions} = options; + const auth: AuthOptions = apiKey !== undefined ? {apiKey: apiKey} : {appId: appId}; + + const publisher = new EventPublisher({ + ...auth, + ...(baseEndpointUrl !== undefined ? {baseEndpointUrl: baseEndpointUrl} : {}), + // Lets the publisher report problems such as suspended tracking + ...(logger !== undefined ? {logger: logger} : {}), + }); + + return publisher.publish(createRequest(event), publishOptions) + .catch(error => { + if (logger !== undefined) { + logger.error(`Failed to track event "${event.type}": ${formatCause(error)}`); + } + + throw error; + }); +} + +function createRequest(event: TrackedEvent): PublishRequest { + switch (event.type) { + case 'userSignedIn': + case 'userSignedOut': + case 'userSignedUp': { + const {payload: {userId, ...payload}, ...request} = event; + + return { + ...request, + payload: { + ...payload, + externalUserId: userId, + }, + }; + } + + default: + return event; + } +} diff --git a/test/api/track.test.ts b/test/api/track.test.ts new file mode 100644 index 00000000..35e695c1 --- /dev/null +++ b/test/api/track.test.ts @@ -0,0 +1,180 @@ +import {EventPublisher} from '@croct/sdk/eventPublisher'; +import type {PublishRequest} from '@croct/sdk/eventPublisher'; +import type {Logger} from '@croct/sdk/logging'; +import type {TrackedEvent, TrackOptions} from '../../src/api'; +import {track} from '../../src/api'; + +const mockPublish: EventPublisher['publish'] = jest.fn(); + +jest.mock( + '@croct/sdk/eventPublisher', + () => ({ + __esModule: true, + /* + * eslint-disable-next-line prefer-arrow-callback -- + * The mock can't be an arrow function because calling new on + * an arrow function is not allowed in JavaScript. + */ + EventPublisher: jest.fn(function constructor(this: EventPublisher) { + this.publish = mockPublish; + }), + }), +); + +describe('track', () => { + const apiKey = '00000000-0000-0000-0000-000000000000'; + const appId = '00000000-0000-0000-0000-000000000000'; + const timestamp = 1700000000000; + + const metadata = { + clientId: '00000000-0000-0000-0000-000000000001', + timestamp: timestamp, + context: { + tabId: '00000000-0000-0000-0000-000000000002', + url: 'https://example.com/products/42', + }, + }; + + const event: TrackedEvent<'goalCompleted'> = { + ...metadata, + type: 'goalCompleted', + payload: { + goalId: 'signup', + value: 10, + }, + }; + + beforeEach(() => { + jest.mocked(mockPublish).mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should forward a server-side tracking request', async () => { + const options: TrackOptions = { + apiKey: apiKey, + timeout: 100, + baseEndpointUrl: 'https://croct.example.com', + }; + + await expect(track(event, options)).resolves.toBeUndefined(); + + expect(EventPublisher).toHaveBeenCalledWith({ + apiKey: options.apiKey, + baseEndpointUrl: options.baseEndpointUrl, + }); + + expect(mockPublish).toHaveBeenCalledWith(event, { + timeout: options.timeout, + }); + }); + + it('should forward a client-side tracking request', async () => { + const options: TrackOptions = { + appId: appId, + extra: {keepalive: true}, + }; + + await expect(track(event, options)).resolves.toBeUndefined(); + + expect(EventPublisher).toHaveBeenCalledWith({ + appId: options.appId, + }); + + expect(mockPublish).toHaveBeenCalledWith(event, { + extra: options.extra, + }); + }); + + it('should forward the user token and client details', async () => { + const trackedEvent: TrackedEvent<'goalCompleted'> = { + ...event, + userToken: 'token', + clientIp: '192.168.0.1', + clientAgent: 'Mozilla/5.0', + }; + + await track(trackedEvent, {appId: appId}); + + expect(mockPublish).toHaveBeenCalledWith(trackedEvent, {}); + }); + + it.each<[string, TrackedEvent, PublishRequest]>([ + [ + 'sign-in', + {...metadata, type: 'userSignedIn', payload: {userId: 'c4r0l'}}, + {...metadata, type: 'userSignedIn', payload: {externalUserId: 'c4r0l'}}, + ], + [ + 'sign-out', + {...metadata, type: 'userSignedOut', payload: {userId: 'c4r0l'}}, + {...metadata, type: 'userSignedOut', payload: {externalUserId: 'c4r0l'}}, + ], + [ + 'sign-up', + { + ...metadata, + type: 'userSignedUp', + payload: { + userId: 'c4r0l', + patch: {operations: [{type: 'merge', path: '.', value: {firstName: 'Carol'}}]}, + }, + }, + { + ...metadata, + type: 'userSignedUp', + payload: { + externalUserId: 'c4r0l', + patch: {operations: [{type: 'merge', path: '.', value: {firstName: 'Carol'}}]}, + }, + }, + ], + ])('should send the user ID of %s events as the external user ID', async (_, trackedEvent, request) => { + await track(trackedEvent, {appId: appId}); + + expect(mockPublish).toHaveBeenCalledWith(request, {}); + }); + + it('should pass the logger to the publisher', async () => { + const logger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + await track(event, {appId: appId, logger: logger}); + + expect(EventPublisher).toHaveBeenCalledWith({ + appId: appId, + logger: logger, + }); + }); + + it('should log and rethrow errors', async () => { + const logger: Logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const error = new Error('Reason'); + + jest.mocked(mockPublish).mockRejectedValue(error); + + await expect(track(event, {apiKey: apiKey, logger: logger})).rejects.toBe(error); + + expect(logger.error).toHaveBeenCalledWith('Failed to track event "goalCompleted": reason'); + }); + + it('should rethrow errors when no logger is provided', async () => { + const error = new Error('Reason'); + + jest.mocked(mockPublish).mockRejectedValue(error); + + await expect(track(event, {apiKey: apiKey})).rejects.toBe(error); + }); +});