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);
+ });
+});