Skip to content

Commit

Permalink
fix: enable batching and capture clicks in autocapture mode
Browse files Browse the repository at this point in the history
  • Loading branch information
christianmat committed Dec 4, 2024
1 parent 77c9928 commit f286470
Show file tree
Hide file tree
Showing 4 changed files with 264 additions and 53 deletions.
6 changes: 6 additions & 0 deletions .changeset/lovely-toes-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'analytics-plugin-trench': patch
'trench-js': patch
---

Enables batching as well as click tracking in autocapture mode
126 changes: 84 additions & 42 deletions packages/analytics-plugin-trench/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ export type TrenchConfig = {
* The Trench API URL. E.g. https://api.trench.dev
*/
serverUrl: string;
/**
* Whether to enable event batching. When enabled, events will be batched together
* and sent periodically or when batch size is reached. Defaults to false.
*/
batchingEnabled?: boolean;
/**
* Maximum number of events to collect before sending a batch. Default is 100.
* Only applies when batchingEnabled is true.
*/
batchSize?: number;
/**
* Maximum time in milliseconds to wait before sending a batch. Default is 5000ms.
* Only applies when batchingEnabled is true.
*/
batchTimeout?: number;
};

export interface BaseEvent {
Expand Down Expand Up @@ -111,11 +126,20 @@ export interface BaseEvent {

const KEY_ANONYMOUS_ID = 'anonymousId';
const KEY_TRAITS = 'traits';
const DEFAULT_BATCH_SIZE = 100;
const DEFAULT_BATCH_TIMEOUT = 5000;

export function trench(config: TrenchConfig) {
const globalPrefix = '__trench__';
let isTrenchLoaded = false;
let anonymousId: string | undefined;
let currentUserId: string | undefined;
let eventBatch: BaseEvent[] = [];
let batchTimeout: NodeJS.Timeout | null = null;

const batchSize = config.batchSize || DEFAULT_BATCH_SIZE;
const batchTimeoutMs = config.batchTimeout || DEFAULT_BATCH_TIMEOUT;

function setGlobalValue(key: string, value: any): void {
const prefixedKey = `${globalPrefix}${key}`;
if (typeof globalThis !== 'undefined') {
Expand Down Expand Up @@ -182,17 +206,43 @@ export function trench(config: TrenchConfig) {
}
}

async function sendEvents(events: BaseEvent[]): Promise<void> {
async function flushEventBatch(): Promise<void> {
if (eventBatch.length === 0) return;

const eventsToSend = [...eventBatch];
eventBatch = [];

if (batchTimeout) {
clearTimeout(batchTimeout);
batchTimeout = null;
}

await sendEvents(eventsToSend);
}

async function queueEvent(event: BaseEvent): Promise<void> {
if (config.enabled === false) {
return;
}

const lastEvents = getGlobalValue<BaseEvent[]>('lastEvents');
if (!config.batchingEnabled) {
await sendEvents([event]);
return;
}

eventBatch.push(event);

if (eventBatch.length >= batchSize) {
await flushEventBatch();
} else if (!batchTimeout) {
batchTimeout = setTimeout(() => flushEventBatch(), batchTimeoutMs);
}
}

if (lastEvents && JSON.stringify(events) === JSON.stringify(lastEvents)) {
async function sendEvents(events: BaseEvent[]): Promise<void> {
if (config.enabled === false) {
return;
}
setGlobalValue('lastEvents', events);

await fetch(`${removeTrailingSlash(config.serverUrl)}/events`, {
method: 'POST',
Expand All @@ -218,33 +268,29 @@ export function trench(config: TrenchConfig) {
return;
}

await sendEvents([
{
anonymousId: payload.userId ? undefined : getAnonymousId(),
userId: payload.userId ?? getAnonymousId(),
event: payload.event,
properties: payload.properties,
context: getContext(),
type: 'track',
},
]);
await queueEvent({
anonymousId: payload.userId ? undefined : getAnonymousId(),
userId: payload.userId ?? getAnonymousId(),
event: payload.event,
properties: payload.properties,
context: getContext(),
type: 'track',
});
},

page: async ({ payload }: { payload: BaseEvent }): Promise<void> => {
if (config.enabled === false) {
return;
}

await sendEvents([
{
anonymousId: payload.userId ? undefined : getAnonymousId(),
userId: payload.userId ?? getAnonymousId(),
event: '$pageview',
properties: payload.properties,
context: getContext(),
type: 'page',
},
]);
await queueEvent({
anonymousId: payload.userId ? undefined : getAnonymousId(),
userId: payload.userId ?? getAnonymousId(),
event: '$pageview',
properties: payload.properties,
context: getContext(),
type: 'page',
});
},

identify: async ({
Expand All @@ -268,15 +314,13 @@ export function trench(config: TrenchConfig) {

setGlobalValue(KEY_TRAITS, traits);

await sendEvents([
{
anonymousId: getAnonymousId(),
userId: payload.userId ?? getAnonymousId(),
event: 'identify',
traits,
type: 'identify',
},
]);
await queueEvent({
anonymousId: getAnonymousId(),
userId: payload.userId ?? getAnonymousId(),
event: 'identify',
traits,
type: 'identify',
});
}
},

Expand All @@ -292,15 +336,13 @@ export function trench(config: TrenchConfig) {
}

if (groupId) {
await sendEvents([
{
userId: getCurrentUserId() ?? getAnonymousId(),
groupId,
event: 'group',
traits,
type: 'group',
},
]);
await queueEvent({
userId: getCurrentUserId() ?? getAnonymousId(),
groupId,
event: 'group',
traits,
type: 'group',
});
}
},
},
Expand Down
159 changes: 159 additions & 0 deletions packages/analytics-plugin-trench/test/analytics-plugin-trench.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,163 @@ describe('analytics-plugin-trench', () => {

expect(mockFetch).toHaveBeenCalledWith('https://api.test.com/events', expect.any(Object));
});

it('should not send duplicate events when batching is disabled', async () => {
const plugin = trench({
...config,
batchingEnabled: false,
});

const payload = {
event: 'test_event',
type: 'track' as const,
};

await plugin.track({ payload });
await plugin.track({ payload }); // Send same event again

expect(mockFetch).toHaveBeenCalledTimes(2); // Should only be called once
});

describe('event batching', () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it('should batch events when batching is enabled', async () => {
const plugin = trench({
...config,
batchingEnabled: true,
batchSize: 2,
});

const payload1 = {
event: 'test_event_1',
type: 'track' as const,
};

const payload2 = {
event: 'test_event_2',
type: 'track' as const,
};

await plugin.track({ payload: payload1 });
expect(mockFetch).not.toHaveBeenCalled();

await plugin.track({ payload: payload2 });
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith('https://api.test.com/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer test-key',
},
body: expect.stringContaining('"event":"test_event_1"'),
});
// @ts-ignore
expect(mockFetch.mock.calls[0][1].body).toContain('"event":"test_event_2"');
});
it('should handle duplicate events in batches when interspersed with other events', async () => {
const plugin = trench({
...config,
batchingEnabled: true,
batchSize: 3,
});

const duplicatePayload = {
event: 'duplicate_event',
type: 'track' as const,
};

const uniquePayload = {
event: 'unique_event',
type: 'track' as const,
};

await plugin.track({ payload: duplicatePayload });
await plugin.track({ payload: uniquePayload });
await plugin.track({ payload: duplicatePayload });

expect(mockFetch).toHaveBeenCalledTimes(1);

// @ts-ignore
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(requestBody.events).toHaveLength(3);
expect(requestBody.events[0].event).toBe('duplicate_event');
expect(requestBody.events[1].event).toBe('unique_event');
expect(requestBody.events[2].event).toBe('duplicate_event');
});

it('shoul not dedupe consecutive duplicate events in batch', async () => {
const plugin = trench({
...config,
batchingEnabled: true,
batchSize: 2,
});

const duplicatePayload = {
event: 'duplicate_event',
type: 'track' as const,
};

// Send same event twice in a row
await plugin.track({ payload: duplicatePayload });
await plugin.track({ payload: duplicatePayload });

expect(mockFetch).toHaveBeenCalledTimes(1);

// @ts-ignore
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(requestBody.events).toHaveLength(2); // Should dedupe to just 2 events
expect(requestBody.events[0].event).toBe('duplicate_event'); // First duplicate
expect(requestBody.events[1].event).toBe('duplicate_event'); // First duplicate
});

it('should flush batch after timeout', async () => {
const plugin = trench({
...config,
batchingEnabled: true,
batchTimeout: 1000,
});

const payload = {
event: 'test_event',
type: 'track' as const,
};

await plugin.track({ payload });
expect(mockFetch).not.toHaveBeenCalled();

jest.advanceTimersByTime(1001);

expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith('https://api.test.com/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer test-key',
},
body: expect.stringContaining('"event":"test_event"'),
});
});

it('should not batch events when batching is disabled', async () => {
const plugin = trench({
...config,
batchingEnabled: false,
});

const payload = {
event: 'test_event',
type: 'track' as const,
};

await plugin.track({ payload });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
});
26 changes: 15 additions & 11 deletions packages/trench-js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class Trench {
});

if (config.autoCaptureEvents) {
if (config.batchingEnabled === undefined) {
config.batchingEnabled = true;
}
this.page({});
this.enableAutoCapture();
}
Expand All @@ -52,17 +55,18 @@ class Trench {
sendPageView();
});

// TODO: Re-enable automatic click tracking once auto-batching events is implemented
// window.addEventListener('click', (event) => {
// const target = event.target as HTMLElement;
// const eventName = target.getAttribute('data-event-name') || 'click';
// this.track(eventName, {
// tagName: target.tagName,
// id: target.id,
// className: target.className,
// textContent: target.textContent,
// });
// });
window.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const eventName = target.getAttribute('data-event-name') || 'click';
if (target.textContent) {
this.track(eventName, {
tagName: target.tagName,
id: target.id,
className: target.className,
textContent: target.textContent,
});
}
});

window.addEventListener('popstate', () => {
sendPageView();
Expand Down

0 comments on commit f286470

Please sign in to comment.