Skip to content

Commit 2ea861c

Browse files
committed
feat(core): add EventBatcher transport decorator for identical event batching
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.
1 parent 3cebca0 commit 2ea861c

4 files changed

Lines changed: 411 additions & 2 deletions

File tree

packages/browser/src/catcher.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { VueIntegrationAddons } from '@hawk.so/types';
77
import type { JavaScriptCatcherIntegrations } from '@/types';
88
import { ConsoleCatcher } from './addons/consoleCatcher';
99
import { BrowserBreadcrumbStore } from './addons/breadcrumbs';
10-
import { BaseCatcher, HawkUserManager, isLoggerSet, log, setLogger, decodeIntegrationId } from '@hawk.so/core';
10+
import { BaseCatcher, HawkUserManager, isLoggerSet, log, setLogger, decodeIntegrationId, EventBatcher } from '@hawk.so/core';
1111
import { HawkLocalStorage } from './utils/hawk-local-storage';
1212
import { createBrowserLogger } from './utils/logger';
1313
import { BrowserRandomGenerator } from './utils/random';
@@ -111,6 +111,11 @@ export default class Catcher extends BaseCatcher<typeof Catcher.type> {
111111
},
112112
});
113113

114+
const batcher = new EventBatcher(transport);
115+
116+
// Flush buffered events before the socket closes on page hide
117+
window.addEventListener('pagehide', () => batcher.flush(), { capture: true });
118+
114119
let breadcrumbStore: BrowserBreadcrumbStore | null = null;
115120

116121
if (token && settings.breadcrumbs !== false) {
@@ -119,7 +124,7 @@ export default class Catcher extends BaseCatcher<typeof Catcher.type> {
119124

120125
super(
121126
token,
122-
transport,
127+
batcher,
123128
userManager,
124129
settings.release !== undefined ? String(settings.release) : undefined,
125130
settings.context || undefined,

packages/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@ export type { MessageProcessor, ProcessingPayload } from './types/message-proces
1616
export { BaseCatcher } from './catcher';
1717
export type { BeforeSendHook } from './catcher';
1818
export { decodeIntegrationId } from './utils/integration-id-decoder';
19+
export { EventBatcher } from './utils/event-batcher';
20+
export type { EventBatcherOptions } from './utils/event-batcher';
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import type { BacktraceFrame, CatcherMessage, CatcherMessageType } from '@hawk.so/types';
2+
import type { Transport } from './transport';
3+
4+
declare module '@hawk.so/types' {
5+
interface CatcherMessage<Type extends CatcherMessageType> {
6+
/**
7+
* Number of identical occurrences this message represents.
8+
* Absent or 1 — treated as a single event by server.
9+
* Greater than 1 — server increments totalCount by this value instead of 1.
10+
*/
11+
count?: number;
12+
}
13+
}
14+
15+
/**
16+
* Minimal shape of payload fields used for signature computation.
17+
*/
18+
interface BatchablePayload {
19+
title?: string;
20+
type?: string;
21+
backtrace?: BacktraceFrame[];
22+
}
23+
24+
/**
25+
* Single entry in batching buffer.
26+
*/
27+
interface BufferEntry<T extends CatcherMessageType> {
28+
/** First occurrence — used as representative event for batch. */
29+
message: CatcherMessage<T>;
30+
count: number;
31+
}
32+
33+
/**
34+
* Options for EventBatcher.
35+
*/
36+
export interface EventBatcherOptions {
37+
/**
38+
* Time window in milliseconds.
39+
* Buffer is flushed after this delay from first event in current window.
40+
*/
41+
flushIntervalMs?: number;
42+
43+
/**
44+
* Maximum number of distinct event signatures in buffer before force-flush.
45+
*/
46+
maxBufferSize?: number;
47+
}
48+
49+
/**
50+
* Transport decorator that batches identical events before forwarding to underlying transport.
51+
*
52+
* Events with same signature (title + type + backtrace frames) are accumulated
53+
* within a time window. On flush, one representative message per signature is forwarded
54+
* with {@link CatcherMessage.count} set to total number of occurrences.
55+
*
56+
* Flush is triggered by whichever condition is met first:
57+
* - Time window expires ({@link EventBatcherOptions.flushIntervalMs} after first event)
58+
* - Buffer reaches {@link EventBatcherOptions.maxBufferSize} distinct signatures
59+
* - {@link flush} is called explicitly
60+
*
61+
* First occurrence is used as representative event for each batch.
62+
* Context, user, and breadcrumbs of subsequent identical occurrences are not preserved.
63+
*/
64+
export class EventBatcher<T extends CatcherMessageType> implements Transport<T> {
65+
private readonly transport: Transport<T>;
66+
private readonly flushIntervalMs: number;
67+
private readonly maxBufferSize: number;
68+
69+
private readonly buffer = new Map<string, BufferEntry<T>>();
70+
private flushTimer: ReturnType<typeof setTimeout> | null = null;
71+
72+
/**
73+
* @param transport - underlying transport to forward flushed batches to
74+
* @param options - optional tuning parameters
75+
*/
76+
public constructor(transport: Transport<T>, options: EventBatcherOptions = {}) {
77+
this.transport = transport;
78+
this.flushIntervalMs = options.flushIntervalMs ?? 5_000;
79+
this.maxBufferSize = options.maxBufferSize ?? 100;
80+
}
81+
82+
/**
83+
* Accepts incoming message. Increments count for known signatures,
84+
* adds new entry for unknown ones, and schedules a flush.
85+
*
86+
* @param message - message to buffer
87+
*/
88+
public async send(message: CatcherMessage<T>): Promise<void> {
89+
const key = computeSignature(message);
90+
const existing = this.buffer.get(key);
91+
92+
if (existing !== undefined) {
93+
existing.count++;
94+
} else {
95+
this.buffer.set(key, { message, count: 1 });
96+
this.scheduleFlush();
97+
}
98+
99+
if (this.buffer.size >= this.maxBufferSize) {
100+
this.flush();
101+
}
102+
}
103+
104+
/**
105+
* Forwards all buffered messages to underlying transport immediately.
106+
* Cancels pending timer if one is active.
107+
* Safe to call when buffer is empty.
108+
*/
109+
public flush(): void {
110+
if (this.flushTimer !== null) {
111+
clearTimeout(this.flushTimer);
112+
this.flushTimer = null;
113+
}
114+
115+
for (const { message, count } of this.buffer.values()) {
116+
void this.transport.send(withCount(message, count));
117+
}
118+
119+
this.buffer.clear();
120+
}
121+
122+
/**
123+
* Schedules a flush after time window expires.
124+
* No-op if a timer is already running.
125+
*/
126+
private scheduleFlush(): void {
127+
if (this.flushTimer !== null) {
128+
return;
129+
}
130+
131+
this.flushTimer = setTimeout(() => {
132+
this.flushTimer = null;
133+
this.flush();
134+
}, this.flushIntervalMs);
135+
}
136+
}
137+
138+
/**
139+
* Computes string key uniquely identifying an event by its semantic content.
140+
*
141+
* Covers: title, type, and per-frame coordinates (file, line, column, function).
142+
* Uses null bytes as field delimiters — safe because error messages and
143+
* source paths do not contain them.
144+
*
145+
* @param message - message to compute signature for
146+
*/
147+
function computeSignature<T extends CatcherMessageType>(message: CatcherMessage<T>): string {
148+
const p = message.payload as BatchablePayload;
149+
150+
const framesSig = p.backtrace
151+
?.map(f => `${f.file}\x01${f.line}\x01${f.column ?? ''}\x01${f.function ?? ''}`)
152+
.join('\x00')
153+
?? '';
154+
155+
return `${p.title ?? ''}\x00${p.type ?? ''}\x00${framesSig}`;
156+
}
157+
158+
/**
159+
* Returns message with count attached.
160+
* Returns original message unchanged when count is 1 —
161+
* server treats absent count as a single occurrence.
162+
*
163+
* @param message - original message
164+
* @param count - number of occurrences
165+
*/
166+
function withCount<T extends CatcherMessageType>(
167+
message: CatcherMessage<T>,
168+
count: number
169+
): CatcherMessage<T> {
170+
if (count <= 1) {
171+
return message;
172+
}
173+
174+
return { ...message, count };
175+
}

0 commit comments

Comments
 (0)