Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './evaluate';
export * from './fetchContent';
export * from './track';
81 changes: 81 additions & 0 deletions src/api/track.ts
Original file line number Diff line number Diff line change
@@ -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> = P extends {externalUserId: infer U}
? Omit<P, 'externalUserId'> & {userId: U}
: P;

export type TrackedEvent<T extends EventType = EventType> = {
// Distributes over the event types so that the type and payload of each event stay correlated
[K in T]: {
type: K,
payload: PublicPayload<EventData<K>['payload']>,
}
}[T] & Omit<PublishRequest, 'type' | 'payload'>;

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<T extends EventType>(event: TrackedEvent<T>, options: TrackOptions): Promise<void>;

export function track(event: TrackedEvent, options: TrackOptions): Promise<void> {
const {apiKey, appId, baseEndpointUrl, logger, ...publishOptions} = options;
const auth: AuthOptions = apiKey !== undefined ? {apiKey: apiKey} : {appId: appId};

const publisher = new EventPublisher({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we pass logger to EventPublisher here? Publisher logs a warning on HTTP 202 - tracking suspended. We're missing it here

...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;
}
}
180 changes: 180 additions & 0 deletions test/api/track.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading