Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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 { 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';
Expand Down Expand Up @@ -111,6 +111,11 @@ export default class Catcher extends BaseCatcher<typeof Catcher.type> {
},
});

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) {
Expand All @@ -119,7 +124,7 @@ export default class Catcher extends BaseCatcher<typeof Catcher.type> {

super(
token,
transport,
batcher,
userManager,
settings.release !== undefined ? String(settings.release) : undefined,
settings.context || undefined,
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 { EventBatcher } from './utils/event-batcher';
export type { EventBatcherOptions } from './utils/event-batcher';
175 changes: 175 additions & 0 deletions packages/core/src/utils/event-batcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import type { BacktraceFrame, CatcherMessage, CatcherMessageType } from '@hawk.so/types';
import type { Transport } from './transport';

declare module '@hawk.so/types' {

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 be added to the types package

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.

interface CatcherMessage<Type extends CatcherMessageType> {
/**
* 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<T extends CatcherMessageType> {
/** First occurrence — used as representative event for batch. */
message: CatcherMessage<T>;
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<T extends CatcherMessageType> implements Transport<T> {

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.

lets call it "dedupe" instead of "batching" (because we will add batching later)

So lets the class EventDedupeTransport

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.

Done.

private readonly transport: Transport<T>;
private readonly flushIntervalMs: number;
private readonly maxBufferSize: number;

private readonly buffer = new Map<string, BufferEntry<T>>();
private flushTimer: ReturnType<typeof setTimeout> | null = null;

/**
* @param transport - underlying transport to forward flushed batches to
* @param options - optional tuning parameters
*/
public constructor(transport: Transport<T>, 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<T>): Promise<void> {
const key = computeSignature(message);
const existing = this.buffer.get(key);

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

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.

if event is not batched, send it immediately (or with small delay to wait for duplicates)

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.

Now sending events with 5sec delay to wait duplicates.

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<T extends CatcherMessageType>(message: CatcherMessage<T>): string {

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.

lets make it work like in Grouper (without backtrace)

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.

Removed backtrace from key.

I think it's ok, if we still represent it as string concat, not hash as grouper does.

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<T extends CatcherMessageType>(
message: CatcherMessage<T>,
count: number
): CatcherMessage<T> {
if (count <= 1) {
return message;
}

return { ...message, count };
}
Loading