Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hawk.so/browser",
"version": "3.3.6",
"version": "3.3.7",
"description": "JavaScript Browser errors tracking for Hawk.so",
"files": [
"dist"
Expand Down
9 changes: 7 additions & 2 deletions packages/browser/src/catcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import type { JavaScriptCatcherIntegrations } from '@/types';
import { ConsoleCatcher } from './addons/consoleCatcher';
import { BrowserBreadcrumbStore } from './addons/breadcrumbs';
import { BaseCatcher, HawkUserManager, isLoggerSet, log, setLogger, decodeIntegrationId } from '@hawk.so/core';
import { BaseCatcher, HawkUserManager, isLoggerSet, log, setLogger, decodeIntegrationId, EventDedupeTransport } from '@hawk.so/core';
import { HawkLocalStorage } from './utils/hawk-local-storage';
import { createBrowserLogger } from './utils/logger';
import { BrowserRandomGenerator } from './utils/random';
Expand Down Expand Up @@ -111,6 +111,11 @@
},
});

const batcher = new EventDedupeTransport(transport);

// Flush buffered events before the socket closes on page hide
window.addEventListener('pagehide', () => batcher.flush(), { capture: true });

let breadcrumbStore: BrowserBreadcrumbStore | null = null;

if (token && settings.breadcrumbs !== false) {
Expand All @@ -119,7 +124,7 @@

super(
token,
transport,
batcher,
userManager,
settings.release !== undefined ? String(settings.release) : undefined,
settings.context || undefined,
Expand Down Expand Up @@ -224,7 +229,7 @@
* - global errors handling
* - performance issue detectors (Long Tasks / LoAF)
*
* @param settings

Check warning on line 232 in packages/browser/src/catcher.ts

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc @param "settings" description
*/
private configureIssues(settings: HawkInitialSettings): void {
if (settings.issues === false) {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hawk.so/core",
"version": "1.0.0",
"version": "1.0.1",
"description": "Base implementation for all Hawk.so JavaScript SDKs",
"files": [
"dist"
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ export type { MessageProcessor, ProcessingPayload } from './types/message-proces
export { BaseCatcher } from './catcher';
export type { BeforeSendHook } from './catcher';
export { decodeIntegrationId } from './utils/integration-id-decoder';
export { EventDedupeTransport } from './utils/event-dedupe-transport';
export type { EventDedupeTransportOptions } from './utils/event-dedupe-transport';
136 changes: 136 additions & 0 deletions packages/core/src/utils/event-dedupe-transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { CatcherMessage, CatcherMessageType } from '@hawk.so/types';
import type { Transport } from './transport';

declare module '@hawk.so/types' {
// eslint-disable-next-line @typescript-eslint/no-shadow, @typescript-eslint/naming-convention
interface CatcherMessage<_Type extends CatcherMessageType> {
/**
* Number of identical occurrences this message represents.
* Absent or 1 — treated as single event by server.
* Greater than 1 — server increments totalCount by this value instead of 1.
*/
count?: number;
}
}

/**
* Options for EventDedupeTransport.
*/
export interface EventDedupeTransportOptions {
/**
* Time window in milliseconds.
* Each unique event is held for this duration to accumulate duplicate count,
* then forwarded once with {@link CatcherMessage.count} set to total occurrences.
*/
windowMs?: number;
}

/**
* Single entry in dedupe buffer.
*/
interface BufferEntry<T extends CatcherMessageType> {
message: CatcherMessage<T>;
count: number;
timer: ReturnType<typeof setTimeout>;
}

/**
* Computes deduplication key from catcher type and event title.
* Matches grouping criteria used by server-side grouper worker.
*
* @param message - message to compute signature for
*/
function computeSignature<T extends CatcherMessageType>(message: CatcherMessage<T>): string {
const title = (message.payload as { title?: string }).title ?? '';

return `${message.catcherType}\x00${title}`;
}

/**
* Returns message with count attached.
* Returns original message unchanged when count is 1 —
* server treats absent count as single occurrence.
*
* @param message - original message
* @param count - number of occurrences
*/
function withCount<T extends CatcherMessageType>(
message: CatcherMessage<T>,
count: number
): CatcherMessage<T> {
if (count <= 1) {
return message;
}

return { ...message,
count };
}

/**
* Transport decorator that deduplicates identical events within a time window.
*
* Events with the same catcher type and title are considered identical.
* First occurrence is buffered and a timer is started; subsequent identical events
* within the window increment the counter without resetting the timer.
* When the window expires, one representative message is forwarded with
* {@link CatcherMessage.count} set to total occurrences.
*
* Each unique event signature has its own independent timer.
* Call {@link EventDedupeTransport.flush} to forward all buffered events immediately (e.g. on page unload).
*/
export class EventDedupeTransport<T extends CatcherMessageType> implements Transport<T> {
private readonly transport: Transport<T>;
private readonly windowMs: number;
private readonly buffer = new Map<string, BufferEntry<T>>();

/**
* @param transport - underlying transport to forward deduplicated events to
* @param options - optional tuning parameters
*/
constructor(transport: Transport<T>, options: EventDedupeTransportOptions = {}) {
this.transport = transport;
this.windowMs = options.windowMs ?? 5_000;
Comment thread
Reversean marked this conversation as resolved.
Outdated
}

/**
* Accepts incoming message. Starts a dedupe window for new signatures;
* increments count for already-buffered signatures.
*
* @param message - message to buffer
*/
public async send(message: CatcherMessage<T>): Promise<void> {
const key = computeSignature(message);
const existing = this.buffer.get(key);

if (existing !== undefined) {
existing.count++;

return;

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 reschedule timer here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I guess so. Done.

}

const timer = setTimeout(() => {
const entry = this.buffer.get(key);

if (entry !== undefined) {
this.buffer.delete(key);
void this.transport.send(withCount(entry.message, entry.count));
}
}, this.windowMs);

this.buffer.set(key, { message,
count: 1,
timer });
}

/**
* Forwards all buffered messages to underlying transport immediately.
* Cancels pending timers. Safe to call when buffer is empty.
*/
public flush(): void {
for (const [key, entry] of this.buffer) {
clearTimeout(entry.timer);
this.buffer.delete(key);
void this.transport.send(withCount(entry.message, entry.count));
}
}
}
196 changes: 196 additions & 0 deletions packages/core/tests/utils/event-dedupe-transport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { CatcherMessage } from '@hawk.so/types';
import { EventDedupeTransport } from '../../src';
import type { Transport } from '../../src';

function makeTransport(): { send: ReturnType<typeof vi.fn>; transport: Transport } {
const send = vi.fn().mockResolvedValue(undefined);

return { send,
transport: { send } };
}

function makeMessage(
title: string,
catcherType: CatcherMessage<'errors/javascript'>['catcherType'] = 'errors/javascript'
): CatcherMessage<'errors/javascript'> {
return {
token: 'test-token',
catcherType,
payload: {
title,
} as CatcherMessage<'errors/javascript'>['payload'],
};
}

const WINDOW_MS = 5_000;

describe('EventDedupeTransport', () => {
beforeEach(() => {
vi.useFakeTimers();
});

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

describe('debouncing', () => {
it('should not forward event until window expires', async () => {
const { send, transport } = makeTransport();
const debouncer = new EventDedupeTransport(transport, { windowMs: WINDOW_MS });

await debouncer.send(makeMessage('TypeError'));
expect(send).not.toHaveBeenCalled();

vi.advanceTimersByTime(WINDOW_MS);
await Promise.resolve();

expect(send).toHaveBeenCalledOnce();
});

it('should forward single occurrence without count field', async () => {
const { send, transport } = makeTransport();
const debouncer = new EventDedupeTransport(transport, { windowMs: WINDOW_MS });

await debouncer.send(makeMessage('TypeError'));
vi.advanceTimersByTime(WINDOW_MS);
await Promise.resolve();

expect(send.mock.calls[0][0].count).toBeUndefined();
});

it('should accumulate duplicates and forward with count', async () => {
const { send, transport } = makeTransport();
const debouncer = new EventDedupeTransport(transport, { windowMs: WINDOW_MS });

const first = makeMessage('TypeError');
const second = makeMessage('TypeError');

second.token = 'other-token';

await debouncer.send(first);
await debouncer.send(second);
await debouncer.send(first);

vi.advanceTimersByTime(WINDOW_MS);
await Promise.resolve();

expect(send).toHaveBeenCalledOnce();

const sent: CatcherMessage<'errors/javascript'> = send.mock.calls[0][0];

expect(sent.count).toBe(3);
expect(sent.token).toBe('test-token');
});

it('should forward distinct events separately', async () => {
const { send, transport } = makeTransport();
const debouncer = new EventDedupeTransport(transport, { windowMs: WINDOW_MS });

await debouncer.send(makeMessage('TypeError'));
await debouncer.send(makeMessage('ReferenceError'));

vi.advanceTimersByTime(WINDOW_MS);
await Promise.resolve();

expect(send).toHaveBeenCalledTimes(2);
});

it('should treat events with different catcherType as distinct', async () => {
const { send, transport } = makeTransport();
const debouncer = new EventDedupeTransport(transport as Transport<'errors/javascript'>, { windowMs: WINDOW_MS });

const msg1 = makeMessage('TypeError', 'errors/javascript');
const msg2 = { ...makeMessage('TypeError'),
catcherType: 'errors/nodejs' } as unknown as CatcherMessage<'errors/javascript'>;

await debouncer.send(msg1);
await debouncer.send(msg2);

vi.advanceTimersByTime(WINDOW_MS);
await Promise.resolve();

expect(send).toHaveBeenCalledTimes(2);
});
});

describe('independent timers per event', () => {
it('should fire each event signature on its own timer', async () => {
const { send, transport } = makeTransport();
const debouncer = new EventDedupeTransport(transport, { windowMs: WINDOW_MS });

await debouncer.send(makeMessage('TypeError'));
vi.advanceTimersByTime(2_000);
await debouncer.send(makeMessage('ReferenceError'));

vi.advanceTimersByTime(3_000);
await Promise.resolve();

expect(send).toHaveBeenCalledOnce();
expect(send.mock.calls[0][0].payload.title).toBe('TypeError');

vi.advanceTimersByTime(2_000);
await Promise.resolve();

expect(send).toHaveBeenCalledTimes(2);
expect(send.mock.calls[1][0].payload.title).toBe('ReferenceError');
});

it('should start fresh window after previous one expires', async () => {
const { send, transport } = makeTransport();
const debouncer = new EventDedupeTransport(transport, { windowMs: WINDOW_MS });
const message = makeMessage('TypeError');

await debouncer.send(message);
vi.advanceTimersByTime(WINDOW_MS);
await Promise.resolve();
expect(send).toHaveBeenCalledOnce();
send.mockClear();

await debouncer.send(message);
await debouncer.send(message);
vi.advanceTimersByTime(WINDOW_MS);
await Promise.resolve();

expect(send).toHaveBeenCalledOnce();
expect(send.mock.calls[0][0].count).toBe(2);
});
});

describe('flush', () => {
it('should forward all buffered events immediately', async () => {
const { send, transport } = makeTransport();
const debouncer = new EventDedupeTransport(transport, { windowMs: WINDOW_MS });

await debouncer.send(makeMessage('TypeError'));
await debouncer.send(makeMessage('TypeError'));
await debouncer.send(makeMessage('ReferenceError'));
expect(send).not.toHaveBeenCalled();

debouncer.flush();
await Promise.resolve();

expect(send).toHaveBeenCalledTimes(2);
});

it('should not re-send after flush when timers fire', async () => {
const { send, transport } = makeTransport();
const debouncer = new EventDedupeTransport(transport, { windowMs: WINDOW_MS });

await debouncer.send(makeMessage('TypeError'));
debouncer.flush();

vi.advanceTimersByTime(WINDOW_MS);
await Promise.resolve();

expect(send).toHaveBeenCalledOnce();
});

it('should be safe to call on empty buffer', () => {
const { transport } = makeTransport();
const debouncer = new EventDedupeTransport(transport);

expect(() => debouncer.flush()).not.toThrow();
});
});
});
Loading