From 2ea861c8048fdae1e8fc10dd4a071148079e0fa3 Mon Sep 17 00:00:00 2001 From: Reversean Date: Wed, 27 May 2026 20:02:12 +0300 Subject: [PATCH 1/5] feat(core): add EventBatcher transport decorator for identical event batching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces EventBatcher — a Transport decorator that accumulates identical events within a configurable time window and forwards one representative message per unique signature with CatcherMessage.count set to the total number of occurrences. Signature is computed from title, type, and per-frame coordinates (file, line, column, function) using null-byte delimiters. Lookup is O(1) via Map keyed on the signature string. Flush is triggered by whichever condition is met first: - time window expires (default 5 s from first event in window) - buffer reaches maxBufferSize distinct signatures (default 100) - flush() is called explicitly (e.g. on pagehide) BrowserCatcher wraps its transport in EventBatcher and registers a capture-phase pagehide listener to drain the buffer before the socket closes. Protocol: CatcherMessage.count is declared via module augmentation on @hawk.so/types. Server must increment totalCount by count (defaulting to 1) and create a single Repetition record per batch. --- packages/browser/src/catcher.ts | 9 +- packages/core/src/index.ts | 2 + packages/core/src/utils/event-batcher.ts | 175 ++++++++++++++ .../core/tests/utils/event-batcher.test.ts | 227 ++++++++++++++++++ 4 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/utils/event-batcher.ts create mode 100644 packages/core/tests/utils/event-batcher.test.ts diff --git a/packages/browser/src/catcher.ts b/packages/browser/src/catcher.ts index 5890adcc..7b00535e 100644 --- a/packages/browser/src/catcher.ts +++ b/packages/browser/src/catcher.ts @@ -7,7 +7,7 @@ import type { VueIntegrationAddons } from '@hawk.so/types'; 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, EventBatcher } from '@hawk.so/core'; import { HawkLocalStorage } from './utils/hawk-local-storage'; import { createBrowserLogger } from './utils/logger'; import { BrowserRandomGenerator } from './utils/random'; @@ -111,6 +111,11 @@ export default class Catcher extends BaseCatcher { }, }); + const batcher = new EventBatcher(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) { @@ -119,7 +124,7 @@ export default class Catcher extends BaseCatcher { super( token, - transport, + batcher, userManager, settings.release !== undefined ? String(settings.release) : undefined, settings.context || undefined, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 22d8313b..f63267dc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 { EventBatcher } from './utils/event-batcher'; +export type { EventBatcherOptions } from './utils/event-batcher'; diff --git a/packages/core/src/utils/event-batcher.ts b/packages/core/src/utils/event-batcher.ts new file mode 100644 index 00000000..22f5fed1 --- /dev/null +++ b/packages/core/src/utils/event-batcher.ts @@ -0,0 +1,175 @@ +import type { BacktraceFrame, CatcherMessage, CatcherMessageType } from '@hawk.so/types'; +import type { Transport } from './transport'; + +declare module '@hawk.so/types' { + interface CatcherMessage { + /** + * Number of identical occurrences this message represents. + * Absent or 1 — treated as a single event by server. + * Greater than 1 — server increments totalCount by this value instead of 1. + */ + count?: number; + } +} + +/** + * Minimal shape of payload fields used for signature computation. + */ +interface BatchablePayload { + title?: string; + type?: string; + backtrace?: BacktraceFrame[]; +} + +/** + * Single entry in batching buffer. + */ +interface BufferEntry { + /** First occurrence — used as representative event for batch. */ + message: CatcherMessage; + count: number; +} + +/** + * Options for EventBatcher. + */ +export interface EventBatcherOptions { + /** + * Time window in milliseconds. + * Buffer is flushed after this delay from first event in current window. + */ + flushIntervalMs?: number; + + /** + * Maximum number of distinct event signatures in buffer before force-flush. + */ + maxBufferSize?: number; +} + +/** + * Transport decorator that batches identical events before forwarding to underlying transport. + * + * Events with same signature (title + type + backtrace frames) are accumulated + * within a time window. On flush, one representative message per signature is forwarded + * with {@link CatcherMessage.count} set to total number of occurrences. + * + * Flush is triggered by whichever condition is met first: + * - Time window expires ({@link EventBatcherOptions.flushIntervalMs} after first event) + * - Buffer reaches {@link EventBatcherOptions.maxBufferSize} distinct signatures + * - {@link flush} is called explicitly + * + * First occurrence is used as representative event for each batch. + * Context, user, and breadcrumbs of subsequent identical occurrences are not preserved. + */ +export class EventBatcher implements Transport { + private readonly transport: Transport; + private readonly flushIntervalMs: number; + private readonly maxBufferSize: number; + + private readonly buffer = new Map>(); + private flushTimer: ReturnType | null = null; + + /** + * @param transport - underlying transport to forward flushed batches to + * @param options - optional tuning parameters + */ + public constructor(transport: Transport, options: EventBatcherOptions = {}) { + this.transport = transport; + this.flushIntervalMs = options.flushIntervalMs ?? 5_000; + this.maxBufferSize = options.maxBufferSize ?? 100; + } + + /** + * Accepts incoming message. Increments count for known signatures, + * adds new entry for unknown ones, and schedules a flush. + * + * @param message - message to buffer + */ + public async send(message: CatcherMessage): Promise { + const key = computeSignature(message); + const existing = this.buffer.get(key); + + if (existing !== undefined) { + existing.count++; + } else { + this.buffer.set(key, { message, count: 1 }); + this.scheduleFlush(); + } + + if (this.buffer.size >= this.maxBufferSize) { + this.flush(); + } + } + + /** + * Forwards all buffered messages to underlying transport immediately. + * Cancels pending timer if one is active. + * Safe to call when buffer is empty. + */ + public flush(): void { + if (this.flushTimer !== null) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + + for (const { message, count } of this.buffer.values()) { + void this.transport.send(withCount(message, count)); + } + + this.buffer.clear(); + } + + /** + * Schedules a flush after time window expires. + * No-op if a timer is already running. + */ + private scheduleFlush(): void { + if (this.flushTimer !== null) { + return; + } + + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + this.flush(); + }, this.flushIntervalMs); + } +} + +/** + * Computes string key uniquely identifying an event by its semantic content. + * + * Covers: title, type, and per-frame coordinates (file, line, column, function). + * Uses null bytes as field delimiters — safe because error messages and + * source paths do not contain them. + * + * @param message - message to compute signature for + */ +function computeSignature(message: CatcherMessage): string { + const p = message.payload as BatchablePayload; + + const framesSig = p.backtrace + ?.map(f => `${f.file}\x01${f.line}\x01${f.column ?? ''}\x01${f.function ?? ''}`) + .join('\x00') + ?? ''; + + return `${p.title ?? ''}\x00${p.type ?? ''}\x00${framesSig}`; +} + +/** + * Returns message with count attached. + * Returns original message unchanged when count is 1 — + * server treats absent count as a single occurrence. + * + * @param message - original message + * @param count - number of occurrences + */ +function withCount( + message: CatcherMessage, + count: number +): CatcherMessage { + if (count <= 1) { + return message; + } + + return { ...message, count }; +} diff --git a/packages/core/tests/utils/event-batcher.test.ts b/packages/core/tests/utils/event-batcher.test.ts new file mode 100644 index 00000000..2fb88fb9 --- /dev/null +++ b/packages/core/tests/utils/event-batcher.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { CatcherMessage } from '@hawk.so/types'; +import { EventBatcher } from '../../src/utils/event-batcher'; +import type { Transport } from '../../src/utils/transport'; + +function makeTransport(): { send: ReturnType; transport: Transport } { + const send = vi.fn().mockResolvedValue(undefined); + return { send, transport: { send } }; +} + +function makeMessage( + title: string, + type = 'Error', + frames: Array<{ file: string; line: number; column?: number; function?: string }> = [] +): CatcherMessage<'errors/javascript'> { + return { + token: 'test-token', + catcherType: 'errors/javascript', + payload: { + title, + type, + backtrace: frames.map(f => ({ + file: f.file, + line: f.line, + column: f.column, + function: f.function, + })), + } as CatcherMessage<'errors/javascript'>['payload'], + }; +} + +const FRAME = { file: 'app.js', line: 10, column: 5, function: 'onClick' }; + +describe('EventBatcher', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('batching identical events', () => { + it('should buffer events until flush interval expires', async () => { + const { send, transport } = makeTransport(); + const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); + + await batcher.send(makeMessage('TypeError', 'TypeError', [FRAME])); + expect(send).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + + expect(send).toHaveBeenCalledOnce(); + }); + + it('should omit count for single occurrence', async () => { + const { send, transport } = makeTransport(); + const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); + + await batcher.send(makeMessage('TypeError', 'TypeError', [FRAME])); + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + + const sent: CatcherMessage<'errors/javascript'> = send.mock.calls[0][0]; + + expect(sent.count).toBeUndefined(); + }); + + it('should send once with correct count and preserve first occurrence data', async () => { + const { send, transport } = makeTransport(); + const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); + + const first = makeMessage('TypeError', 'TypeError', [FRAME]); + const second = makeMessage('TypeError', 'TypeError', [FRAME]); + second.token = 'other-token'; + + await batcher.send(first); + await batcher.send(second); + await batcher.send(first); + + vi.advanceTimersByTime(5_000); + 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'); + }); + }); + + describe('grouping by signature', () => { + it('should send separate events for distinct error types', async () => { + const { send, transport } = makeTransport(); + const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); + + await batcher.send(makeMessage('TypeError', 'TypeError', [FRAME])); + await batcher.send(makeMessage('ReferenceError', 'ReferenceError', [FRAME])); + + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + + expect(send).toHaveBeenCalledTimes(2); + }); + + it('should treat same error at different frame line as distinct', async () => { + const { send, transport } = makeTransport(); + const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); + + await batcher.send(makeMessage('TypeError', 'TypeError', [{ file: 'app.js', line: 10 }])); + await batcher.send(makeMessage('TypeError', 'TypeError', [{ file: 'app.js', line: 20 }])); + + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + + expect(send).toHaveBeenCalledTimes(2); + }); + + it('should batch identical and send distinct separately', async () => { + const { send, transport } = makeTransport(); + const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); + + const typeError = makeMessage('TypeError', 'TypeError', [FRAME]); + const refError = makeMessage('ReferenceError', 'ReferenceError', [FRAME]); + + await batcher.send(typeError); + await batcher.send(refError); + await batcher.send(typeError); + await batcher.send(typeError); + + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + + expect(send).toHaveBeenCalledTimes(2); + + const sentMessages = send.mock.calls.map(c => c[0] as CatcherMessage<'errors/javascript'>); + const typeErrorSent = sentMessages.find(m => m.payload.title === 'TypeError'); + const refErrorSent = sentMessages.find(m => m.payload.title === 'ReferenceError'); + + expect(typeErrorSent?.count).toBe(3); + expect(refErrorSent?.count).toBeUndefined(); + }); + + it('should batch events without backtrace', async () => { + const { send, transport } = makeTransport(); + const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); + + const message = makeMessage('Script error.'); + message.payload.backtrace = undefined; + + await batcher.send(message); + await batcher.send(message); + + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + + expect(send).toHaveBeenCalledOnce(); + expect(send.mock.calls[0][0].count).toBe(2); + }); + }); + + describe('flush triggers', () => { + it('should force-flush when buffer reaches max size', async () => { + const { send, transport } = makeTransport(); + const batcher = new EventBatcher(transport, { flushIntervalMs: 60_000, maxBufferSize: 3 }); + + await batcher.send(makeMessage('Error A', 'Error', [{ file: 'a.js', line: 1 }])); + await batcher.send(makeMessage('Error B', 'Error', [{ file: 'b.js', line: 1 }])); + expect(send).not.toHaveBeenCalled(); + + await batcher.send(makeMessage('Error C', 'Error', [{ file: 'c.js', line: 1 }])); + await Promise.resolve(); + + expect(send).toHaveBeenCalledTimes(3); + }); + + it('should flush immediately on explicit call', async () => { + const { send, transport } = makeTransport(); + const batcher = new EventBatcher(transport, { flushIntervalMs: 60_000 }); + + await batcher.send(makeMessage('TypeError', 'TypeError', [FRAME])); + await batcher.send(makeMessage('TypeError', 'TypeError', [FRAME])); + expect(send).not.toHaveBeenCalled(); + + batcher.flush(); + await Promise.resolve(); + + expect(send).toHaveBeenCalledOnce(); + expect(send.mock.calls[0][0].count).toBe(2); + }); + + it('should not re-flush after explicit call empties buffer', async () => { + const { send, transport } = makeTransport(); + const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); + + await batcher.send(makeMessage('TypeError', 'TypeError', [FRAME])); + batcher.flush(); + + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + + expect(send).toHaveBeenCalledOnce(); + }); + + it('should send fresh batch after flush', async () => { + const { send, transport } = makeTransport(); + const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); + const message = makeMessage('TypeError', 'TypeError', [FRAME]); + + await batcher.send(message); + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + expect(send).toHaveBeenCalledOnce(); + send.mockClear(); + + await batcher.send(message); + await batcher.send(message); + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + + expect(send).toHaveBeenCalledOnce(); + expect(send.mock.calls[0][0].count).toBe(2); + }); + }); +}); From 654b3e6a76a3ac6c78dc9add731fabd2570e1973 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:25:07 +0000 Subject: [PATCH 2/5] chore: bump @hawk.so/core version --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index 6797c920..84329cae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" From 58d38f8540c82c9ca77a0491332f737f39127032 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:25:12 +0000 Subject: [PATCH 3/5] chore: bump @hawk.so/browser version --- packages/browser/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser/package.json b/packages/browser/package.json index ed7b7546..e2443b83 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -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" From 07cc623fd92d99f89498441cf095581a82afe14d Mon Sep 17 00:00:00 2001 From: Reversean Date: Mon, 1 Jun 2026 21:40:41 +0300 Subject: [PATCH 4/5] fix: review fixes - Renamed EventBatcher to EventDedupeTransport - removed backtrace from event key for debounce --- packages/browser/src/catcher.ts | 4 +- packages/core/src/index.ts | 4 +- packages/core/src/utils/event-batcher.ts | 175 -------------- .../core/src/utils/event-dedupe-transport.ts | 136 +++++++++++ .../core/tests/utils/event-batcher.test.ts | 227 ------------------ .../utils/event-dedupe-transport.test.ts | 196 +++++++++++++++ 6 files changed, 336 insertions(+), 406 deletions(-) delete mode 100644 packages/core/src/utils/event-batcher.ts create mode 100644 packages/core/src/utils/event-dedupe-transport.ts delete mode 100644 packages/core/tests/utils/event-batcher.test.ts create mode 100644 packages/core/tests/utils/event-dedupe-transport.test.ts diff --git a/packages/browser/src/catcher.ts b/packages/browser/src/catcher.ts index 7b00535e..f86305b1 100644 --- a/packages/browser/src/catcher.ts +++ b/packages/browser/src/catcher.ts @@ -7,7 +7,7 @@ import type { VueIntegrationAddons } from '@hawk.so/types'; import type { JavaScriptCatcherIntegrations } from '@/types'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BrowserBreadcrumbStore } from './addons/breadcrumbs'; -import { BaseCatcher, HawkUserManager, isLoggerSet, log, setLogger, decodeIntegrationId, EventBatcher } 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'; @@ -111,7 +111,7 @@ export default class Catcher extends BaseCatcher { }, }); - const batcher = new EventBatcher(transport); + const batcher = new EventDedupeTransport(transport); // Flush buffered events before the socket closes on page hide window.addEventListener('pagehide', () => batcher.flush(), { capture: true }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f63267dc..bb57da32 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,5 +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 { EventBatcher } from './utils/event-batcher'; -export type { EventBatcherOptions } from './utils/event-batcher'; +export { EventDedupeTransport } from './utils/event-dedupe-transport'; +export type { EventDedupeTransportOptions } from './utils/event-dedupe-transport'; diff --git a/packages/core/src/utils/event-batcher.ts b/packages/core/src/utils/event-batcher.ts deleted file mode 100644 index 22f5fed1..00000000 --- a/packages/core/src/utils/event-batcher.ts +++ /dev/null @@ -1,175 +0,0 @@ -import type { BacktraceFrame, CatcherMessage, CatcherMessageType } from '@hawk.so/types'; -import type { Transport } from './transport'; - -declare module '@hawk.so/types' { - interface CatcherMessage { - /** - * Number of identical occurrences this message represents. - * Absent or 1 — treated as a single event by server. - * Greater than 1 — server increments totalCount by this value instead of 1. - */ - count?: number; - } -} - -/** - * Minimal shape of payload fields used for signature computation. - */ -interface BatchablePayload { - title?: string; - type?: string; - backtrace?: BacktraceFrame[]; -} - -/** - * Single entry in batching buffer. - */ -interface BufferEntry { - /** First occurrence — used as representative event for batch. */ - message: CatcherMessage; - count: number; -} - -/** - * Options for EventBatcher. - */ -export interface EventBatcherOptions { - /** - * Time window in milliseconds. - * Buffer is flushed after this delay from first event in current window. - */ - flushIntervalMs?: number; - - /** - * Maximum number of distinct event signatures in buffer before force-flush. - */ - maxBufferSize?: number; -} - -/** - * Transport decorator that batches identical events before forwarding to underlying transport. - * - * Events with same signature (title + type + backtrace frames) are accumulated - * within a time window. On flush, one representative message per signature is forwarded - * with {@link CatcherMessage.count} set to total number of occurrences. - * - * Flush is triggered by whichever condition is met first: - * - Time window expires ({@link EventBatcherOptions.flushIntervalMs} after first event) - * - Buffer reaches {@link EventBatcherOptions.maxBufferSize} distinct signatures - * - {@link flush} is called explicitly - * - * First occurrence is used as representative event for each batch. - * Context, user, and breadcrumbs of subsequent identical occurrences are not preserved. - */ -export class EventBatcher implements Transport { - private readonly transport: Transport; - private readonly flushIntervalMs: number; - private readonly maxBufferSize: number; - - private readonly buffer = new Map>(); - private flushTimer: ReturnType | null = null; - - /** - * @param transport - underlying transport to forward flushed batches to - * @param options - optional tuning parameters - */ - public constructor(transport: Transport, options: EventBatcherOptions = {}) { - this.transport = transport; - this.flushIntervalMs = options.flushIntervalMs ?? 5_000; - this.maxBufferSize = options.maxBufferSize ?? 100; - } - - /** - * Accepts incoming message. Increments count for known signatures, - * adds new entry for unknown ones, and schedules a flush. - * - * @param message - message to buffer - */ - public async send(message: CatcherMessage): Promise { - const key = computeSignature(message); - const existing = this.buffer.get(key); - - if (existing !== undefined) { - existing.count++; - } else { - this.buffer.set(key, { message, count: 1 }); - this.scheduleFlush(); - } - - if (this.buffer.size >= this.maxBufferSize) { - this.flush(); - } - } - - /** - * Forwards all buffered messages to underlying transport immediately. - * Cancels pending timer if one is active. - * Safe to call when buffer is empty. - */ - public flush(): void { - if (this.flushTimer !== null) { - clearTimeout(this.flushTimer); - this.flushTimer = null; - } - - for (const { message, count } of this.buffer.values()) { - void this.transport.send(withCount(message, count)); - } - - this.buffer.clear(); - } - - /** - * Schedules a flush after time window expires. - * No-op if a timer is already running. - */ - private scheduleFlush(): void { - if (this.flushTimer !== null) { - return; - } - - this.flushTimer = setTimeout(() => { - this.flushTimer = null; - this.flush(); - }, this.flushIntervalMs); - } -} - -/** - * Computes string key uniquely identifying an event by its semantic content. - * - * Covers: title, type, and per-frame coordinates (file, line, column, function). - * Uses null bytes as field delimiters — safe because error messages and - * source paths do not contain them. - * - * @param message - message to compute signature for - */ -function computeSignature(message: CatcherMessage): string { - const p = message.payload as BatchablePayload; - - const framesSig = p.backtrace - ?.map(f => `${f.file}\x01${f.line}\x01${f.column ?? ''}\x01${f.function ?? ''}`) - .join('\x00') - ?? ''; - - return `${p.title ?? ''}\x00${p.type ?? ''}\x00${framesSig}`; -} - -/** - * Returns message with count attached. - * Returns original message unchanged when count is 1 — - * server treats absent count as a single occurrence. - * - * @param message - original message - * @param count - number of occurrences - */ -function withCount( - message: CatcherMessage, - count: number -): CatcherMessage { - if (count <= 1) { - return message; - } - - return { ...message, count }; -} diff --git a/packages/core/src/utils/event-dedupe-transport.ts b/packages/core/src/utils/event-dedupe-transport.ts new file mode 100644 index 00000000..3a3c8483 --- /dev/null +++ b/packages/core/src/utils/event-dedupe-transport.ts @@ -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 { + message: CatcherMessage; + count: number; + timer: ReturnType; +} + +/** + * 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(message: CatcherMessage): 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( + message: CatcherMessage, + count: number +): CatcherMessage { + 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 implements Transport { + private readonly transport: Transport; + private readonly windowMs: number; + private readonly buffer = new Map>(); + + /** + * @param transport - underlying transport to forward deduplicated events to + * @param options - optional tuning parameters + */ + constructor(transport: Transport, options: EventDedupeTransportOptions = {}) { + this.transport = transport; + this.windowMs = options.windowMs ?? 5_000; + } + + /** + * 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): Promise { + const key = computeSignature(message); + const existing = this.buffer.get(key); + + if (existing !== undefined) { + existing.count++; + + return; + } + + 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)); + } + } +} diff --git a/packages/core/tests/utils/event-batcher.test.ts b/packages/core/tests/utils/event-batcher.test.ts deleted file mode 100644 index 2fb88fb9..00000000 --- a/packages/core/tests/utils/event-batcher.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { CatcherMessage } from '@hawk.so/types'; -import { EventBatcher } from '../../src/utils/event-batcher'; -import type { Transport } from '../../src/utils/transport'; - -function makeTransport(): { send: ReturnType; transport: Transport } { - const send = vi.fn().mockResolvedValue(undefined); - return { send, transport: { send } }; -} - -function makeMessage( - title: string, - type = 'Error', - frames: Array<{ file: string; line: number; column?: number; function?: string }> = [] -): CatcherMessage<'errors/javascript'> { - return { - token: 'test-token', - catcherType: 'errors/javascript', - payload: { - title, - type, - backtrace: frames.map(f => ({ - file: f.file, - line: f.line, - column: f.column, - function: f.function, - })), - } as CatcherMessage<'errors/javascript'>['payload'], - }; -} - -const FRAME = { file: 'app.js', line: 10, column: 5, function: 'onClick' }; - -describe('EventBatcher', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - describe('batching identical events', () => { - it('should buffer events until flush interval expires', async () => { - const { send, transport } = makeTransport(); - const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); - - await batcher.send(makeMessage('TypeError', 'TypeError', [FRAME])); - expect(send).not.toHaveBeenCalled(); - - vi.advanceTimersByTime(5_000); - await Promise.resolve(); - - expect(send).toHaveBeenCalledOnce(); - }); - - it('should omit count for single occurrence', async () => { - const { send, transport } = makeTransport(); - const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); - - await batcher.send(makeMessage('TypeError', 'TypeError', [FRAME])); - vi.advanceTimersByTime(5_000); - await Promise.resolve(); - - const sent: CatcherMessage<'errors/javascript'> = send.mock.calls[0][0]; - - expect(sent.count).toBeUndefined(); - }); - - it('should send once with correct count and preserve first occurrence data', async () => { - const { send, transport } = makeTransport(); - const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); - - const first = makeMessage('TypeError', 'TypeError', [FRAME]); - const second = makeMessage('TypeError', 'TypeError', [FRAME]); - second.token = 'other-token'; - - await batcher.send(first); - await batcher.send(second); - await batcher.send(first); - - vi.advanceTimersByTime(5_000); - 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'); - }); - }); - - describe('grouping by signature', () => { - it('should send separate events for distinct error types', async () => { - const { send, transport } = makeTransport(); - const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); - - await batcher.send(makeMessage('TypeError', 'TypeError', [FRAME])); - await batcher.send(makeMessage('ReferenceError', 'ReferenceError', [FRAME])); - - vi.advanceTimersByTime(5_000); - await Promise.resolve(); - - expect(send).toHaveBeenCalledTimes(2); - }); - - it('should treat same error at different frame line as distinct', async () => { - const { send, transport } = makeTransport(); - const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); - - await batcher.send(makeMessage('TypeError', 'TypeError', [{ file: 'app.js', line: 10 }])); - await batcher.send(makeMessage('TypeError', 'TypeError', [{ file: 'app.js', line: 20 }])); - - vi.advanceTimersByTime(5_000); - await Promise.resolve(); - - expect(send).toHaveBeenCalledTimes(2); - }); - - it('should batch identical and send distinct separately', async () => { - const { send, transport } = makeTransport(); - const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); - - const typeError = makeMessage('TypeError', 'TypeError', [FRAME]); - const refError = makeMessage('ReferenceError', 'ReferenceError', [FRAME]); - - await batcher.send(typeError); - await batcher.send(refError); - await batcher.send(typeError); - await batcher.send(typeError); - - vi.advanceTimersByTime(5_000); - await Promise.resolve(); - - expect(send).toHaveBeenCalledTimes(2); - - const sentMessages = send.mock.calls.map(c => c[0] as CatcherMessage<'errors/javascript'>); - const typeErrorSent = sentMessages.find(m => m.payload.title === 'TypeError'); - const refErrorSent = sentMessages.find(m => m.payload.title === 'ReferenceError'); - - expect(typeErrorSent?.count).toBe(3); - expect(refErrorSent?.count).toBeUndefined(); - }); - - it('should batch events without backtrace', async () => { - const { send, transport } = makeTransport(); - const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); - - const message = makeMessage('Script error.'); - message.payload.backtrace = undefined; - - await batcher.send(message); - await batcher.send(message); - - vi.advanceTimersByTime(5_000); - await Promise.resolve(); - - expect(send).toHaveBeenCalledOnce(); - expect(send.mock.calls[0][0].count).toBe(2); - }); - }); - - describe('flush triggers', () => { - it('should force-flush when buffer reaches max size', async () => { - const { send, transport } = makeTransport(); - const batcher = new EventBatcher(transport, { flushIntervalMs: 60_000, maxBufferSize: 3 }); - - await batcher.send(makeMessage('Error A', 'Error', [{ file: 'a.js', line: 1 }])); - await batcher.send(makeMessage('Error B', 'Error', [{ file: 'b.js', line: 1 }])); - expect(send).not.toHaveBeenCalled(); - - await batcher.send(makeMessage('Error C', 'Error', [{ file: 'c.js', line: 1 }])); - await Promise.resolve(); - - expect(send).toHaveBeenCalledTimes(3); - }); - - it('should flush immediately on explicit call', async () => { - const { send, transport } = makeTransport(); - const batcher = new EventBatcher(transport, { flushIntervalMs: 60_000 }); - - await batcher.send(makeMessage('TypeError', 'TypeError', [FRAME])); - await batcher.send(makeMessage('TypeError', 'TypeError', [FRAME])); - expect(send).not.toHaveBeenCalled(); - - batcher.flush(); - await Promise.resolve(); - - expect(send).toHaveBeenCalledOnce(); - expect(send.mock.calls[0][0].count).toBe(2); - }); - - it('should not re-flush after explicit call empties buffer', async () => { - const { send, transport } = makeTransport(); - const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); - - await batcher.send(makeMessage('TypeError', 'TypeError', [FRAME])); - batcher.flush(); - - vi.advanceTimersByTime(5_000); - await Promise.resolve(); - - expect(send).toHaveBeenCalledOnce(); - }); - - it('should send fresh batch after flush', async () => { - const { send, transport } = makeTransport(); - const batcher = new EventBatcher(transport, { flushIntervalMs: 5_000 }); - const message = makeMessage('TypeError', 'TypeError', [FRAME]); - - await batcher.send(message); - vi.advanceTimersByTime(5_000); - await Promise.resolve(); - expect(send).toHaveBeenCalledOnce(); - send.mockClear(); - - await batcher.send(message); - await batcher.send(message); - vi.advanceTimersByTime(5_000); - await Promise.resolve(); - - expect(send).toHaveBeenCalledOnce(); - expect(send.mock.calls[0][0].count).toBe(2); - }); - }); -}); diff --git a/packages/core/tests/utils/event-dedupe-transport.test.ts b/packages/core/tests/utils/event-dedupe-transport.test.ts new file mode 100644 index 00000000..3df8a39a --- /dev/null +++ b/packages/core/tests/utils/event-dedupe-transport.test.ts @@ -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; 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(); + }); + }); +}); From b906ad04484f62be06f684ac47f26ad01660d04f Mon Sep 17 00:00:00 2001 From: Reversean Date: Wed, 3 Jun 2026 16:02:09 +0300 Subject: [PATCH 5/5] fix: review fixes - Decreased debounce window to 2.5s - Reset timer on repeated message --- packages/core/src/utils/event-dedupe-transport.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/event-dedupe-transport.ts b/packages/core/src/utils/event-dedupe-transport.ts index 3a3c8483..75f217f2 100644 --- a/packages/core/src/utils/event-dedupe-transport.ts +++ b/packages/core/src/utils/event-dedupe-transport.ts @@ -89,7 +89,7 @@ export class EventDedupeTransport implements Trans */ constructor(transport: Transport, options: EventDedupeTransportOptions = {}) { this.transport = transport; - this.windowMs = options.windowMs ?? 5_000; + this.windowMs = options.windowMs ?? 2_500; } /** @@ -102,8 +102,17 @@ export class EventDedupeTransport implements Trans const key = computeSignature(message); const existing = this.buffer.get(key); + const sendEntry = (entry: BufferEntry): void => { + if (entry !== undefined) { + this.buffer.delete(key); + void this.transport.send(withCount(entry.message, entry.count)); + } + }; + if (existing !== undefined) { + clearTimeout(existing.timer); existing.count++; + existing.timer = setTimeout(() => sendEntry(existing), this.windowMs); return; } @@ -112,8 +121,7 @@ export class EventDedupeTransport implements Trans const entry = this.buffer.get(key); if (entry !== undefined) { - this.buffer.delete(key); - void this.transport.send(withCount(entry.message, entry.count)); + sendEntry(entry); } }, this.windowMs);