Skip to content

Commit a563368

Browse files
committed
refactor(core): BaseCatcher added
Env-agnostic logic from Catcher in @hawk.so/javascript moved in new abstract BaseCatcher so general logic (breadcrumbs management, user management, context management, message pre-processing and sending, and other utilities) may be reused in other platform-specific implementations. Browser-specific logic (UI-framework integrations, window event listeners, ConsoleCatcher) remain in original Catcher in @hawk.so/javascript.
1 parent 89fbc13 commit a563368

9 files changed

Lines changed: 797 additions & 445 deletions

File tree

packages/core/src/catcher.ts

Lines changed: 458 additions & 0 deletions
Large diffs are not rendered by default.

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ export { buildElementSelector } from './utils/selector';
1313
export { isErrorProcessed, markErrorAsProcessed } from './utils/event';
1414
export type { BreadcrumbStore, BreadcrumbsAPI, BreadcrumbHint, BreadcrumbInput } from './types/breadcrumb-store';
1515
export type { MessageProcessor, ProcessingPayload } from './types/message-processor';
16+
export { BaseCatcher } from './catcher';
17+
export type { BeforeSendHook } from './catcher';
18+
export { decodeIntegrationId } from './utils/integration-id-decoder'
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { DecodedIntegrationToken, EncodedIntegrationToken } from '@hawk.so/types';
2+
3+
/**
4+
* Decodes and returns integration id from integration token.
5+
*
6+
* @param token - encoded integration token
7+
*/
8+
export function decodeIntegrationId(token: EncodedIntegrationToken): string {
9+
const integrationId = decodeIntegrationToken(token);
10+
11+
if (!integrationId || integrationId === '') {
12+
throw new Error('Invalid integration token.');
13+
}
14+
15+
return integrationId;
16+
}
17+
18+
function decodeIntegrationToken(token: EncodedIntegrationToken): string {
19+
try {
20+
const decodedIntegrationToken: DecodedIntegrationToken = JSON.parse(atob(token));
21+
const { integrationId } = decodedIntegrationToken;
22+
23+
return integrationId;
24+
} catch {
25+
throw new Error('Invalid integration token.');
26+
}
27+
}
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import type { CatcherMessage, EventContext } from '@hawk.so/types';
3+
import { BaseCatcher, HawkUserManager } from '../src';
4+
import type {
5+
BeforeSendHook,
6+
Transport,
7+
BreadcrumbStore,
8+
BreadcrumbInput,
9+
MessageProcessor,
10+
HawkStorage,
11+
RandomGenerator,
12+
} from '../src';
13+
14+
vi.mock('../../src/utils/logger', () => ({
15+
log: vi.fn(),
16+
isLoggerSet: vi.fn(() => false),
17+
setLogger: vi.fn(),
18+
resetLogger: vi.fn(),
19+
}));
20+
21+
type TestType = 'errors/javascript';
22+
23+
class TestCatcher extends BaseCatcher<TestType> {
24+
constructor(
25+
token: string,
26+
transport: Transport<TestType>,
27+
userManager: HawkUserManager,
28+
release?: string,
29+
context?: EventContext,
30+
beforeSend?: BeforeSendHook<TestType>,
31+
breadcrumbStore?: BreadcrumbStore
32+
) {
33+
super(token, transport, userManager, release, context, beforeSend, breadcrumbStore);
34+
}
35+
36+
protected getCatcherType(): TestType {
37+
return 'errors/javascript';
38+
}
39+
40+
protected getCatcherVersion(): string {
41+
return '0.0.0-test';
42+
}
43+
44+
public async run(error: Error | string, integrationAddons?: Record<string, unknown>, context?: EventContext): Promise<void> {
45+
return this.formatAndSend(error, integrationAddons, context);
46+
}
47+
48+
public addProcessor(...processors: MessageProcessor<TestType>[]): void {
49+
this.addMessageProcessor(...processors);
50+
}
51+
}
52+
53+
function makeUserManager(): HawkUserManager {
54+
const storage: HawkStorage = {
55+
getItem: vi.fn().mockReturnValue('anon-id'),
56+
setItem: vi.fn(),
57+
removeItem: vi.fn(),
58+
};
59+
const random: RandomGenerator = {
60+
getRandomNumbers: vi.fn().mockReturnValue(new Uint8Array(40)),
61+
};
62+
return new HawkUserManager(storage, random);
63+
}
64+
65+
function makeTransport() {
66+
const send = vi.fn<[CatcherMessage<TestType>], Promise<void>>().mockResolvedValue(undefined);
67+
return { send, transport: { send } as Transport<TestType> };
68+
}
69+
70+
describe('BaseCatcher', () => {
71+
describe('message processors', () => {
72+
it('should not send event when a processor returns null', async () => {
73+
const { send, transport } = makeTransport();
74+
const catcher = new TestCatcher('token', transport, makeUserManager());
75+
76+
catcher.addProcessor({ apply: () => null });
77+
78+
await catcher.run(new Error('test'));
79+
80+
expect(send).not.toHaveBeenCalled();
81+
});
82+
83+
it('should stop pipeline at first null-returning processor and skip remaining ones', async () => {
84+
const { send, transport } = makeTransport();
85+
const catcher = new TestCatcher('token', transport, makeUserManager());
86+
const secondProcessor: MessageProcessor<TestType> = { apply: vi.fn((p) => p) };
87+
88+
catcher.addProcessor({ apply: () => null }, secondProcessor);
89+
90+
await catcher.run(new Error('test'));
91+
92+
expect(send).not.toHaveBeenCalled();
93+
expect(secondProcessor.apply).not.toHaveBeenCalled();
94+
});
95+
96+
it('should send event when all processors pass the payload through', async () => {
97+
const { send, transport } = makeTransport();
98+
const catcher = new TestCatcher('token', transport, makeUserManager());
99+
100+
catcher.addProcessor({ apply: (p) => p }, { apply: (p) => p });
101+
102+
await catcher.run(new Error('test'));
103+
104+
expect(send).toHaveBeenCalledOnce();
105+
});
106+
});
107+
108+
describe('beforeSend hook', () => {
109+
it('should drop event when beforeSend returns false', async () => {
110+
const { send, transport } = makeTransport();
111+
const catcher = new TestCatcher('token', transport, makeUserManager(), undefined, undefined, () => false);
112+
113+
await catcher.run(new Error('test'));
114+
115+
expect(send).not.toHaveBeenCalled();
116+
});
117+
118+
it('should send returned payload when beforeSend returns a valid modified event', async () => {
119+
const { send, transport } = makeTransport();
120+
const beforeSend: BeforeSendHook<TestType> = (event) => ({ ...event, title: 'replaced' });
121+
const catcher = new TestCatcher('token', transport, makeUserManager(), undefined, undefined, beforeSend);
122+
123+
await catcher.run(new Error('original'));
124+
125+
expect(send).toHaveBeenCalledOnce();
126+
expect(send.mock.calls[0][0].payload.title).toBe('replaced');
127+
});
128+
129+
it('should send original event when beforeSend mutates the clone without returning', async () => {
130+
const { send, transport } = makeTransport();
131+
const beforeSend: BeforeSendHook<TestType> = (event) => {
132+
event.title = 'mutated';
133+
// no return — clone was mutated but original should be sent
134+
};
135+
const catcher = new TestCatcher('token', transport, makeUserManager(), undefined, undefined, beforeSend);
136+
137+
await catcher.run(new Error('original'));
138+
139+
expect(send).toHaveBeenCalledOnce();
140+
expect(send.mock.calls[0][0].payload.title).toBe('original');
141+
});
142+
143+
it('should send original event when beforeSend returns an invalid value', async () => {
144+
const { send, transport } = makeTransport();
145+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
146+
const beforeSend: BeforeSendHook<TestType> = () => 42 as any;
147+
const catcher = new TestCatcher('token', transport, makeUserManager(), undefined, undefined, beforeSend);
148+
149+
await catcher.run(new Error('original'));
150+
151+
expect(send).toHaveBeenCalledOnce();
152+
expect(send.mock.calls[0][0].payload.title).toBe('original');
153+
});
154+
});
155+
156+
describe('context merging', () => {
157+
it('should include instance context when no per-call context is provided', async () => {
158+
const { send, transport } = makeTransport();
159+
const instanceContext = { env: 'production', version: '1' };
160+
const catcher = new TestCatcher('token', transport, makeUserManager(), undefined, instanceContext);
161+
162+
await catcher.run(new Error('test'));
163+
164+
expect(send.mock.calls[0][0].payload.context).toEqual(instanceContext);
165+
});
166+
167+
it('should let per-call context override instance context keys', async () => {
168+
const { send, transport } = makeTransport();
169+
const catcher = new TestCatcher(
170+
'token', transport, makeUserManager(), undefined,
171+
{ env: 'production', version: '1' }
172+
);
173+
174+
await catcher.run(new Error('test'), undefined, { version: '2', extra: 'data' });
175+
176+
expect(send.mock.calls[0][0].payload.context).toEqual({ env: 'production', version: '2', extra: 'data' });
177+
});
178+
179+
it('should use only per-call context when no instance context is set', async () => {
180+
const { send, transport } = makeTransport();
181+
const catcher = new TestCatcher('token', transport, makeUserManager());
182+
183+
await catcher.run(new Error('test'), undefined, { requestId: 'abc' });
184+
185+
expect(send.mock.calls[0][0].payload.context).toEqual({ requestId: 'abc' });
186+
});
187+
});
188+
189+
describe('breadcrumbs', () => {
190+
it('should delegate add() to the store', () => {
191+
const store: BreadcrumbStore = { add: vi.fn(), get: vi.fn().mockReturnValue([]), clear: vi.fn() };
192+
const { transport } = makeTransport();
193+
const catcher = new TestCatcher('token', transport, makeUserManager(), undefined, undefined, undefined, store);
194+
const crumb: BreadcrumbInput = { message: 'user clicked' };
195+
196+
catcher.breadcrumbs.add(crumb);
197+
198+
expect(store.add).toHaveBeenCalledWith(crumb, undefined);
199+
});
200+
201+
it('should delegate get() to the store', () => {
202+
const crumbs = [{ message: 'click', timestamp: 1000 }];
203+
const store: BreadcrumbStore = { add: vi.fn(), get: vi.fn().mockReturnValue(crumbs), clear: vi.fn() };
204+
const { transport } = makeTransport();
205+
const catcher = new TestCatcher('token', transport, makeUserManager(), undefined, undefined, undefined, store);
206+
207+
expect(catcher.breadcrumbs.get()).toBe(crumbs);
208+
});
209+
210+
it('should delegate clear() to the store', () => {
211+
const store: BreadcrumbStore = { add: vi.fn(), get: vi.fn().mockReturnValue([]), clear: vi.fn() };
212+
const { transport } = makeTransport();
213+
const catcher = new TestCatcher('token', transport, makeUserManager(), undefined, undefined, undefined, store);
214+
215+
catcher.breadcrumbs.clear();
216+
217+
expect(store.clear).toHaveBeenCalledOnce();
218+
});
219+
220+
it('should return empty array from get() when no store is configured', () => {
221+
const { transport } = makeTransport();
222+
const catcher = new TestCatcher('token', transport, makeUserManager());
223+
224+
expect(catcher.breadcrumbs.get()).toEqual([]);
225+
});
226+
227+
it('should not throw when add() is called without a store', () => {
228+
const { transport } = makeTransport();
229+
const catcher = new TestCatcher('token', transport, makeUserManager());
230+
231+
expect(() => catcher.breadcrumbs.add({ message: 'test' })).not.toThrow();
232+
});
233+
234+
it('should not throw when clear() is called without a store', () => {
235+
const { transport } = makeTransport();
236+
const catcher = new TestCatcher('token', transport, makeUserManager());
237+
238+
expect(() => catcher.breadcrumbs.clear()).not.toThrow();
239+
});
240+
});
241+
});

packages/javascript/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hawk.so/javascript",
3-
"version": "3.3.1",
3+
"version": "3.3.2",
44
"description": "JavaScript errors tracking for Hawk.so",
55
"files": [
66
"dist"

packages/javascript/src/addons/browser-breadcrumbs-message-processor.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { MessageProcessor, ProcessingPayload } from '@hawk.so/core';
2-
import type { BreadcrumbsOptions } from '../addons/breadcrumbs';
3-
import { BrowserBreadcrumbStore } from '../addons/breadcrumbs';
2+
import type { BreadcrumbsOptions } from './breadcrumbs';
3+
import { BrowserBreadcrumbStore } from './breadcrumbs';
44
import type { ErrorsCatcherType } from '@hawk.so/types/src/catchers/catcher-message';
55

66
/**

packages/javascript/src/addons/console-output-addon-message-processor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { MessageProcessor, ProcessingPayload } from '@hawk.so/core';
2-
import type { ConsoleCatcher } from '../addons/consoleCatcher';
2+
import type { ConsoleCatcher } from './consoleCatcher';
33

44
/**
55
* Attaches captured console output to payload addons.

0 commit comments

Comments
 (0)