From 1b2b7dfec9bb97f01fb779632c56337e2476d092 Mon Sep 17 00:00:00 2001 From: Reversean Date: Tue, 3 Mar 2026 19:58:57 +0300 Subject: [PATCH 1/4] refactor(core): BreadcrumbStore abstraction and browser implementation added --- .../core/src/breadcrumbs/breadcrumb-store.ts | 27 ++++++ packages/core/src/index.ts | 1 + packages/javascript/src/addons/breadcrumbs.ts | 51 +++++----- packages/javascript/src/catcher.ts | 25 ++--- .../javascript/src/types/breadcrumbs-api.ts | 11 --- packages/javascript/src/types/index.ts | 10 +- packages/javascript/tests/breadcrumbs.test.ts | 92 +++++++++---------- .../javascript/tests/catcher.addons.test.ts | 4 +- .../tests/catcher.breadcrumbs.test.ts | 4 +- .../javascript/tests/catcher.context.test.ts | 4 +- .../tests/catcher.global-handlers.test.ts | 4 +- packages/javascript/tests/catcher.test.ts | 8 +- .../tests/catcher.transport.test.ts | 4 +- .../javascript/tests/catcher.user.test.ts | 4 +- 14 files changed, 134 insertions(+), 115 deletions(-) create mode 100644 packages/core/src/breadcrumbs/breadcrumb-store.ts delete mode 100644 packages/javascript/src/types/breadcrumbs-api.ts diff --git a/packages/core/src/breadcrumbs/breadcrumb-store.ts b/packages/core/src/breadcrumbs/breadcrumb-store.ts new file mode 100644 index 00000000..54e2eeec --- /dev/null +++ b/packages/core/src/breadcrumbs/breadcrumb-store.ts @@ -0,0 +1,27 @@ +import type { Breadcrumb } from '@hawk.so/types'; + +/** + * Hint passed to beforeBreadcrumb callback. + */ +export interface BreadcrumbHint { + [key: string]: unknown; +} + +/** + * Breadcrumb input type - breadcrumb data with optional timestamp. + */ +export type BreadcrumbInput = Omit & { timestamp?: number }; + +/** + * Contract for breadcrumb storage. Also serves as public breadcrumbs API. + */ +export interface BreadcrumbStore { + add(breadcrumb: BreadcrumbInput, hint?: BreadcrumbHint): void; + get(): Breadcrumb[]; + clear(): void; +} + +/** + * @deprecated Use {@link BreadcrumbStore} instead. + */ +export type BreadcrumbsAPI = BreadcrumbStore; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fb1d91f7..30fe1af4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,3 +12,4 @@ export { StackParser } from './modules/stack-parser'; export { buildElementSelector } from './utils/selector'; export { EventRejectedError } from './errors'; export { isErrorProcessed, markErrorAsProcessed } from './utils/event'; +export type { BreadcrumbStore, BreadcrumbsAPI, BreadcrumbHint, BreadcrumbInput } from './breadcrumbs/breadcrumb-store'; diff --git a/packages/javascript/src/addons/breadcrumbs.ts b/packages/javascript/src/addons/breadcrumbs.ts index c953e790..c0d05c10 100644 --- a/packages/javascript/src/addons/breadcrumbs.ts +++ b/packages/javascript/src/addons/breadcrumbs.ts @@ -2,6 +2,7 @@ * @file Breadcrumbs module - captures chronological trail of events before an error */ import type { Breadcrumb, BreadcrumbLevel, BreadcrumbType, Json, JsonNode } from '@hawk.so/types'; +import type { BreadcrumbHint, BreadcrumbInput, BreadcrumbStore } from '@hawk.so/core'; import { buildElementSelector, isValidBreadcrumb, log, Sanitizer } from '@hawk.so/core'; /** @@ -10,9 +11,10 @@ import { buildElementSelector, isValidBreadcrumb, log, Sanitizer } from '@hawk.s const DEFAULT_MAX_BREADCRUMBS = 15; /** - * Hint object passed to beforeBreadcrumb callback + * Hint object passed to beforeBreadcrumb callback. + * Extends generic {@link BreadcrumbHint} with browser-specific data. */ -export interface BreadcrumbHint { +export interface BrowserBreadcrumbHint extends BreadcrumbHint { /** * Original event that triggered the breadcrumb (if any) */ @@ -51,7 +53,7 @@ export interface BreadcrumbsOptions { * - Return `false` — the breadcrumb will be discarded. * - Any other value is invalid — the original breadcrumb is stored as-is (a warning is logged). */ - beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => Breadcrumb | false | void; + beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BrowserBreadcrumbHint) => Breadcrumb | false | void; /** * Enable automatic fetch/XHR breadcrumbs @@ -75,12 +77,6 @@ export interface BreadcrumbsOptions { trackClicks?: boolean; } -/** - * Breadcrumb input type - breadcrumb data with optional timestamp - * (timestamp will be auto-generated if not provided) - */ -export type BreadcrumbInput = Omit & { timestamp?: Breadcrumb['timestamp'] }; - /** * Internal breadcrumbs options - all fields except 'beforeBreadcrumb' are required * (they have default values and are always set during init) @@ -90,17 +86,18 @@ interface InternalBreadcrumbsOptions { trackFetch: boolean; trackNavigation: boolean; trackClicks: boolean; - beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => Breadcrumb | false | void; + beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BrowserBreadcrumbHint) => Breadcrumb | false | void; } /** - * BreadcrumbManager - singleton that manages breadcrumb collection and storage + * Browser implementation of BreadcrumbStore. + * Singleton that manages breadcrumb collection and storage. */ -export class BreadcrumbManager { +export class BrowserBreadcrumbStore implements BreadcrumbStore { /** * Singleton instance */ - private static instance: BreadcrumbManager | null = null; + private static instance: BrowserBreadcrumbStore | null = null; /** * Breadcrumbs buffer (FIFO) @@ -167,10 +164,10 @@ export class BreadcrumbManager { /** * Get singleton instance */ - public static getInstance(): BreadcrumbManager { - BreadcrumbManager.instance ??= new BreadcrumbManager(); + public static getInstance(): BrowserBreadcrumbStore { + BrowserBreadcrumbStore.instance ??= new BrowserBreadcrumbStore(); - return BreadcrumbManager.instance; + return BrowserBreadcrumbStore.instance; } /** @@ -180,8 +177,6 @@ export class BreadcrumbManager { */ public init(options: BreadcrumbsOptions = {}): void { if (this.isInitialized) { - log('[BreadcrumbManager] init has already been called; breadcrumb configuration is global and subsequent init options are ignored.', 'warn'); - return; } @@ -219,7 +214,7 @@ export class BreadcrumbManager { * @param hint - Optional hint object with original event data (Event, Response, XMLHttpRequest, etc.) * Used by beforeBreadcrumb callback to access original event context */ - public addBreadcrumb(breadcrumb: BreadcrumbInput, hint?: BreadcrumbHint): void { + public add(breadcrumb: BreadcrumbInput, hint?: BrowserBreadcrumbHint): void { /** * Ensure timestamp */ @@ -293,14 +288,14 @@ export class BreadcrumbManager { /** * Get current breadcrumbs snapshot (oldest to newest) */ - public getBreadcrumbs(): Breadcrumb[] { + public get(): Breadcrumb[] { return [ ...this.breadcrumbs ]; } /** * Clear all breadcrumbs */ - public clearBreadcrumbs(): void { + public clear(): void { this.breadcrumbs.length = 0; } @@ -358,9 +353,9 @@ export class BreadcrumbManager { this.popstateHandler = null; } - this.clearBreadcrumbs(); + this.clear(); this.isInitialized = false; - BreadcrumbManager.instance = null; + BrowserBreadcrumbStore.instance = null; } @@ -399,7 +394,7 @@ export class BreadcrumbManager { const duration = Date.now() - startTime; - manager.addBreadcrumb({ + manager.add({ type: 'request', category: 'fetch', message: `${response.status} ${method} ${url}`, @@ -419,7 +414,7 @@ export class BreadcrumbManager { } catch (error) { const duration = Date.now() - startTime; - manager.addBreadcrumb({ + manager.add({ type: 'request', category: 'fetch', message: `[FAIL] ${method} ${url}`, @@ -483,7 +478,7 @@ export class BreadcrumbManager { const url = this.hawkUrl || ''; const status = this.status; - manager.addBreadcrumb({ + manager.add({ type: 'request', category: 'xhr', message: `${status} ${method} ${url}`, @@ -529,7 +524,7 @@ export class BreadcrumbManager { lastUrl = to; - manager.addBreadcrumb({ + manager.add({ type: 'navigation', category: 'navigation', message: `Navigated to ${to}`, @@ -599,7 +594,7 @@ export class BreadcrumbManager { */ const text = (target.textContent || target.innerText || '').trim().substring(0, 50); - manager.addBreadcrumb({ + manager.add({ type: 'ui', category: 'ui.click', message: `Click on ${selector}`, diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 08b0e889..46d98f54 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -1,6 +1,6 @@ import './modules/element-sanitizer'; import Socket from './modules/socket'; -import type { BreadcrumbsAPI, CatcherMessage, HawkInitialSettings, HawkJavaScriptEvent, Transport } from './types'; +import type { CatcherMessage, HawkInitialSettings, HawkJavaScriptEvent, Transport } from './types'; import { VueIntegration } from './integrations/vue'; import type { AffectedUser, @@ -13,7 +13,8 @@ import type { } from '@hawk.so/types'; import type { JavaScriptCatcherIntegrations } from '@/types'; import { ConsoleCatcher } from './addons/consoleCatcher'; -import { BreadcrumbManager } from './addons/breadcrumbs'; +import { BrowserBreadcrumbStore } from './addons/breadcrumbs'; +import type { BreadcrumbStore } from '@hawk.so/core'; import { EventRejectedError, HawkUserManager, @@ -121,9 +122,9 @@ export default class Catcher { private readonly consoleCatcher: ConsoleCatcher | null = null; /** - * Breadcrumb manager instance + * Breadcrumb store instance */ - private readonly breadcrumbManager: BreadcrumbManager | null; + private readonly breadcrumbStore: BrowserBreadcrumbStore | null; /** * Manages currently authenticated user identity. @@ -195,10 +196,10 @@ export default class Catcher { * Initialize breadcrumbs */ if (settings.breadcrumbs !== false) { - this.breadcrumbManager = BreadcrumbManager.getInstance(); - this.breadcrumbManager.init(settings.breadcrumbs ?? {}); + this.breadcrumbStore = BrowserBreadcrumbStore.getInstance(); + this.breadcrumbStore.init(settings.breadcrumbs ?? {}); } else { - this.breadcrumbManager = null; + this.breadcrumbStore = null; } /** @@ -297,11 +298,11 @@ export default class Catcher { * data: { userId: '123' } * }); */ - public get breadcrumbs(): BreadcrumbsAPI { + public get breadcrumbs(): BreadcrumbStore { return { - add: (breadcrumb, hint) => this.breadcrumbManager?.addBreadcrumb(breadcrumb, hint), - get: () => this.breadcrumbManager?.getBreadcrumbs() ?? [], - clear: () => this.breadcrumbManager?.clearBreadcrumbs(), + add: (breadcrumb, hint) => this.breadcrumbStore?.add(breadcrumb, hint), + get: () => this.breadcrumbStore?.get() ?? [], + clear: () => this.breadcrumbStore?.clear(), }; } @@ -578,7 +579,7 @@ export default class Catcher { * Get breadcrumbs for event payload */ private getBreadcrumbsForEvent(): HawkJavaScriptEvent['breadcrumbs'] { - const breadcrumbs = this.breadcrumbManager?.getBreadcrumbs(); + const breadcrumbs = this.breadcrumbStore?.get(); return breadcrumbs && breadcrumbs.length > 0 ? breadcrumbs : undefined; } diff --git a/packages/javascript/src/types/breadcrumbs-api.ts b/packages/javascript/src/types/breadcrumbs-api.ts deleted file mode 100644 index 777dcdf3..00000000 --- a/packages/javascript/src/types/breadcrumbs-api.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Breadcrumb } from '@hawk.so/types'; -import type { BreadcrumbInput, BreadcrumbHint } from '../addons/breadcrumbs'; - -/** - * Breadcrumbs API interface - */ -export interface BreadcrumbsAPI { - add: (breadcrumb: BreadcrumbInput, hint?: BreadcrumbHint) => void; - get: () => Breadcrumb[]; - clear: () => void; -} diff --git a/packages/javascript/src/types/index.ts b/packages/javascript/src/types/index.ts index f2829160..5cf529ad 100644 --- a/packages/javascript/src/types/index.ts +++ b/packages/javascript/src/types/index.ts @@ -1,9 +1,14 @@ import type { CatcherMessage } from './catcher-message'; import type { HawkInitialSettings } from './hawk-initial-settings'; import type { Transport } from '@hawk.so/core'; +import type { BreadcrumbsAPI, BreadcrumbStore } from '@hawk.so/core'; import type { HawkJavaScriptEvent } from './event'; -import type { VueIntegrationData, NuxtIntegrationData, NuxtIntegrationAddons, JavaScriptCatcherIntegrations } from './integrations'; -import type { BreadcrumbsAPI } from './breadcrumbs-api'; +import type { + JavaScriptCatcherIntegrations, + NuxtIntegrationAddons, + NuxtIntegrationData, + VueIntegrationData +} from './integrations'; export type { CatcherMessage, @@ -14,5 +19,6 @@ export type { NuxtIntegrationData, NuxtIntegrationAddons, JavaScriptCatcherIntegrations, + BreadcrumbStore, BreadcrumbsAPI }; diff --git a/packages/javascript/tests/breadcrumbs.test.ts b/packages/javascript/tests/breadcrumbs.test.ts index 7afec05c..9147e81d 100644 --- a/packages/javascript/tests/breadcrumbs.test.ts +++ b/packages/javascript/tests/breadcrumbs.test.ts @@ -1,14 +1,14 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; import type { Breadcrumb } from '@hawk.so/types'; import * as core from '@hawk.so/core'; function resetManager(): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any - (BreadcrumbManager as any).instance = null; + (BrowserBreadcrumbStore as any).instance?.destroy(); } -describe('BreadcrumbManager', () => { +describe('BrowserBreadcrumbStore', () => { let logSpy: ReturnType; beforeEach(() => { @@ -21,19 +21,19 @@ describe('BreadcrumbManager', () => { }); it('should return empty array when no breadcrumbs added', () => { - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init(); - expect(m.getBreadcrumbs()).toEqual([]); + expect(m.get()).toEqual([]); }); it('should store breadcrumb with auto-generated timestamp', () => { - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init(); - m.addBreadcrumb({ type: 'default', message: 'test', level: 'info' }); + m.add({ type: 'default', message: 'test', level: 'info' }); - const crumbs = m.getBreadcrumbs(); + const crumbs = m.get(); expect(crumbs).toHaveLength(1); expect(crumbs[0].message).toBe('test'); @@ -41,24 +41,24 @@ describe('BreadcrumbManager', () => { }); it('should keep explicit timestamp as-is', () => { - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init(); - m.addBreadcrumb({ type: 'default', message: 'test', level: 'info', timestamp: 12345 }); + m.add({ type: 'default', message: 'test', level: 'info', timestamp: 12345 }); - expect(m.getBreadcrumbs()[0].timestamp).toBe(12345); + expect(m.get()[0].timestamp).toBe(12345); }); it('should drop oldest breadcrumbs when buffer overflows (FIFO)', () => { - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init({ maxBreadcrumbs: 3 }); for (let i = 0; i < 5; i++) { - m.addBreadcrumb({ type: 'default', message: `msg-${i}`, level: 'info' }); + m.add({ type: 'default', message: `msg-${i}`, level: 'info' }); } - const crumbs = m.getBreadcrumbs(); + const crumbs = m.get(); expect(crumbs).toHaveLength(3); expect(crumbs[0].message).toBe('msg-2'); @@ -66,55 +66,55 @@ describe('BreadcrumbManager', () => { }); it('should store max 15 breadcrumbs by default', () => { - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init(); for (let i = 0; i < 20; i++) { - m.addBreadcrumb({ type: 'default', message: `msg-${i}`, level: 'info' }); + m.add({ type: 'default', message: `msg-${i}`, level: 'info' }); } - expect(m.getBreadcrumbs()).toHaveLength(15); + expect(m.get()).toHaveLength(15); }); it('should empty buffer on clear', () => { - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init(); - m.addBreadcrumb({ type: 'default', message: 'test', level: 'info' }); - m.clearBreadcrumbs(); + m.add({ type: 'default', message: 'test', level: 'info' }); + m.clear(); - expect(m.getBreadcrumbs()).toEqual([]); + expect(m.get()).toEqual([]); }); it('should return a copy, not the internal array', () => { - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init(); - m.addBreadcrumb({ type: 'default', message: 'test', level: 'info' }); + m.add({ type: 'default', message: 'test', level: 'info' }); - const first = m.getBreadcrumbs(); - const second = m.getBreadcrumbs(); + const first = m.get(); + const second = m.get(); expect(first).not.toBe(second); expect(first).toEqual(second); first.push({ type: 'default', message: 'injected', level: 'info', timestamp: 0 } as Breadcrumb); - expect(m.getBreadcrumbs()).toHaveLength(1); + expect(m.get()).toHaveLength(1); }); it('should ignore second init call', () => { - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init({ maxBreadcrumbs: 5 }); m.init({ maxBreadcrumbs: 100 }); for (let i = 0; i < 10; i++) { - m.addBreadcrumb({ type: 'default', message: `msg-${i}`, level: 'info' }); + m.add({ type: 'default', message: `msg-${i}`, level: 'info' }); } - expect(m.getBreadcrumbs()).toHaveLength(5); + expect(m.get()).toHaveLength(5); }); }); @@ -132,7 +132,7 @@ describe('beforeBreadcrumb', () => { it('should store modified breadcrumb when hook returns changed object', () => { // Arrange - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init({ beforeBreadcrumb(bc) { @@ -143,25 +143,25 @@ describe('beforeBreadcrumb', () => { }); // Act - m.addBreadcrumb({ type: 'default', message: 'original', level: 'info' }); + m.add({ type: 'default', message: 'original', level: 'info' }); // Assert - expect(m.getBreadcrumbs()[0].message).toBe('MODIFIED'); + expect(m.get()[0].message).toBe('MODIFIED'); }); it('should not store breadcrumb when hook returns false', () => { // Arrange - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init({ beforeBreadcrumb: () => false, }); // Act - m.addBreadcrumb({ type: 'default', message: 'drop', level: 'info' }); + m.add({ type: 'default', message: 'drop', level: 'info' }); // Assert - expect(m.getBreadcrumbs()).toHaveLength(0); + expect(m.get()).toHaveLength(0); }); it.each([ @@ -172,7 +172,7 @@ describe('beforeBreadcrumb', () => { { label: 'true', value: true }, ])('should store original breadcrumb and warn when hook returns $label', ({ value }) => { // Arrange - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init({ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -180,10 +180,10 @@ describe('beforeBreadcrumb', () => { }); // Act - m.addBreadcrumb({ type: 'default', message: 'original', level: 'info' }); + m.add({ type: 'default', message: 'original', level: 'info' }); // Assert - expect(m.getBreadcrumbs()[0].message).toBe('original'); + expect(m.get()[0].message).toBe('original'); expect(logSpy).toHaveBeenCalledWith( expect.stringContaining('Invalid beforeBreadcrumb value'), 'warn' @@ -192,7 +192,7 @@ describe('beforeBreadcrumb', () => { it('should store original breadcrumb and warn when hook deletes required field (message)', () => { // Arrange - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init({ beforeBreadcrumb(bc) { @@ -204,15 +204,15 @@ describe('beforeBreadcrumb', () => { }); // Act - m.addBreadcrumb({ type: 'default', message: 'keep-me', level: 'info' }); + m.add({ type: 'default', message: 'keep-me', level: 'info' }); // Assert — fallback to original, message preserved - expect(m.getBreadcrumbs()[0].message).toBe('keep-me'); + expect(m.get()[0].message).toBe('keep-me'); }); it('should filter breadcrumbs by category using hook', () => { // Arrange - const m = BreadcrumbManager.getInstance(); + const m = BrowserBreadcrumbStore.getInstance(); m.init({ beforeBreadcrumb(bc) { @@ -221,11 +221,11 @@ describe('beforeBreadcrumb', () => { }); // Act - m.addBreadcrumb({ type: 'default', message: 'public', level: 'info', category: 'public' }); - m.addBreadcrumb({ type: 'default', message: 'secret', level: 'info', category: 'secret' }); + m.add({ type: 'default', message: 'public', level: 'info', category: 'public' }); + m.add({ type: 'default', message: 'secret', level: 'info', category: 'secret' }); // Assert - const crumbs = m.getBreadcrumbs(); + const crumbs = m.get(); expect(crumbs).toHaveLength(1); expect(crumbs[0].message).toBe('public'); diff --git a/packages/javascript/tests/catcher.addons.test.ts b/packages/javascript/tests/catcher.addons.test.ts index ff8c5689..a4681e57 100644 --- a/packages/javascript/tests/catcher.addons.test.ts +++ b/packages/javascript/tests/catcher.addons.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); @@ -11,7 +11,7 @@ describe('Catcher', () => { beforeEach(() => { localStorage.clear(); mockParse.mockResolvedValue([]); - (BreadcrumbManager as any).instance = null; + (BrowserBreadcrumbStore as any).instance?.destroy(); }); // ── Environment addons ──────────────────────────────────────────────────── diff --git a/packages/javascript/tests/catcher.breadcrumbs.test.ts b/packages/javascript/tests/catcher.breadcrumbs.test.ts index 45cd450a..b0d28245 100644 --- a/packages/javascript/tests/catcher.breadcrumbs.test.ts +++ b/packages/javascript/tests/catcher.breadcrumbs.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); @@ -11,7 +11,7 @@ describe('Catcher', () => { beforeEach(() => { localStorage.clear(); mockParse.mockResolvedValue([]); - (BreadcrumbManager as any).instance = null; + (BrowserBreadcrumbStore as any).instance?.destroy(); }); // ── Breadcrumbs trail ───────────────────────────────────────────────────── diff --git a/packages/javascript/tests/catcher.context.test.ts b/packages/javascript/tests/catcher.context.test.ts index 231b653c..5e71579f 100644 --- a/packages/javascript/tests/catcher.context.test.ts +++ b/packages/javascript/tests/catcher.context.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); @@ -11,7 +11,7 @@ describe('Catcher', () => { beforeEach(() => { localStorage.clear(); mockParse.mockResolvedValue([]); - (BreadcrumbManager as any).instance = null; + (BrowserBreadcrumbStore as any).instance?.destroy(); }); // ── Context enrichment ──────────────────────────────────────────────────── diff --git a/packages/javascript/tests/catcher.global-handlers.test.ts b/packages/javascript/tests/catcher.global-handlers.test.ts index 34c18108..b897e3e7 100644 --- a/packages/javascript/tests/catcher.global-handlers.test.ts +++ b/packages/javascript/tests/catcher.global-handlers.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import Catcher from '../src/catcher'; -import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; import { TEST_TOKEN, wait, createTransport, getLastPayload } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); @@ -12,7 +12,7 @@ describe('Catcher', () => { beforeEach(() => { localStorage.clear(); mockParse.mockResolvedValue([]); - (BreadcrumbManager as any).instance = null; + (BrowserBreadcrumbStore as any).instance?.destroy(); }); // ── Global error handlers ───────────────────────────────────────────────── diff --git a/packages/javascript/tests/catcher.test.ts b/packages/javascript/tests/catcher.test.ts index 25a363a7..55046574 100644 --- a/packages/javascript/tests/catcher.test.ts +++ b/packages/javascript/tests/catcher.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import Catcher from '../src/catcher'; -import { BreadcrumbManager } from '../src/addons/breadcrumbs'; -import { TEST_TOKEN, wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; +import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; +import { createCatcher, createTransport, getLastPayload, TEST_TOKEN, wait } from './catcher.helpers'; // StackParser is mocked to prevent real network calls to source files in the jsdom environment. const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); @@ -17,7 +17,7 @@ describe('Catcher', () => { beforeEach(() => { localStorage.clear(); mockParse.mockResolvedValue([]); - (BreadcrumbManager as any).instance = null; + (BrowserBreadcrumbStore as any).instance?.destroy(); }); // ── Constructor variants ────────────────────────────────────────────────── diff --git a/packages/javascript/tests/catcher.transport.test.ts b/packages/javascript/tests/catcher.transport.test.ts index a59f4b1e..2e7c7e3b 100644 --- a/packages/javascript/tests/catcher.transport.test.ts +++ b/packages/javascript/tests/catcher.transport.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; import type { Transport } from '../src'; import { wait, createCatcher, createTransport } from './catcher.helpers'; @@ -12,7 +12,7 @@ describe('Catcher', () => { beforeEach(() => { localStorage.clear(); mockParse.mockResolvedValue([]); - (BreadcrumbManager as any).instance = null; + (BrowserBreadcrumbStore as any).instance?.destroy(); }); describe('transport failure', () => { diff --git a/packages/javascript/tests/catcher.user.test.ts b/packages/javascript/tests/catcher.user.test.ts index 6f2d29a8..28043ced 100644 --- a/packages/javascript/tests/catcher.user.test.ts +++ b/packages/javascript/tests/catcher.user.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { BreadcrumbManager } from '../src/addons/breadcrumbs'; +import { BrowserBreadcrumbStore } from '../src/addons/breadcrumbs'; import { wait, createTransport, getLastPayload, createCatcher } from './catcher.helpers'; const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); @@ -11,7 +11,7 @@ describe('Catcher', () => { beforeEach(() => { localStorage.clear(); mockParse.mockResolvedValue([]); - (BreadcrumbManager as any).instance = null; + (BrowserBreadcrumbStore as any).instance?.destroy(); }); // ── User identity ───────────────────────────────────────────────────────── From c049bd4030cebd70df8e3302bf8df9c02710f5b8 Mon Sep 17 00:00:00 2001 From: Reversean Date: Wed, 25 Mar 2026 21:28:09 +0300 Subject: [PATCH 2/4] refactor(javascript): MessageProcessor abstraction added and implemented in catcher event processing pipeline - MessageProcessor interface and MessageHint type - BrowserMessageProcessor, BreadcrumbsMessageProcessor, ConsoleCatcherMessageProcessor, DebugMessageProcessor - replaced inline addon logic with sequential MessageProcessor pipeline --- packages/core/src/errors.ts | 12 - packages/core/src/index.ts | 2 +- .../core/src/messages/message-processor.ts | 43 +++ packages/core/src/modules/sanitizer.ts | 1 - packages/javascript/package.json | 2 +- .../javascript/src/addons/userAgentInfo.ts | 17 - packages/javascript/src/catcher.ts | 294 +++++++----------- .../browser-addon-message-processor.ts | 61 ++++ .../browser-breadcrumbs-message-processor.ts | 38 +++ .../console-output-addon-message-processor.ts | 38 +++ .../messages/debug-addon-message-processor.ts | 31 ++ .../src/types/hawk-initial-settings.ts | 9 +- .../tests/catcher.breadcrumbs.test.ts | 6 +- .../tests/catcher.message-processor.ts | 60 ++++ .../breadcrumbs-message-processor.test.ts | 28 ++ .../browser-message-processor.test.ts | 48 +++ .../console-catcher-message-processor.test.ts | 28 ++ .../messages/debug-message-processor.test.ts | 31 ++ .../messages/message-processor.helpers.ts | 7 + 19 files changed, 541 insertions(+), 215 deletions(-) delete mode 100644 packages/core/src/errors.ts create mode 100644 packages/core/src/messages/message-processor.ts delete mode 100644 packages/javascript/src/addons/userAgentInfo.ts create mode 100644 packages/javascript/src/messages/browser-addon-message-processor.ts create mode 100644 packages/javascript/src/messages/browser-breadcrumbs-message-processor.ts create mode 100644 packages/javascript/src/messages/console-output-addon-message-processor.ts create mode 100644 packages/javascript/src/messages/debug-addon-message-processor.ts create mode 100644 packages/javascript/tests/catcher.message-processor.ts create mode 100644 packages/javascript/tests/messages/breadcrumbs-message-processor.test.ts create mode 100644 packages/javascript/tests/messages/browser-message-processor.test.ts create mode 100644 packages/javascript/tests/messages/console-catcher-message-processor.test.ts create mode 100644 packages/javascript/tests/messages/debug-message-processor.test.ts create mode 100644 packages/javascript/tests/messages/message-processor.helpers.ts diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts deleted file mode 100644 index 8fcc5823..00000000 --- a/packages/core/src/errors.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Error triggered when event was rejected by beforeSend method - */ -export class EventRejectedError extends Error { - /** - * @param message - error message - */ - constructor(message: string) { - super(message); - this.name = 'EventRejectedError'; - } -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 30fe1af4..4b05aaf9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,6 @@ export type { Transport } from './transports/transport'; export type { SanitizerTypeHandler } from './modules/sanitizer'; export { StackParser } from './modules/stack-parser'; export { buildElementSelector } from './utils/selector'; -export { EventRejectedError } from './errors'; export { isErrorProcessed, markErrorAsProcessed } from './utils/event'; export type { BreadcrumbStore, BreadcrumbsAPI, BreadcrumbHint, BreadcrumbInput } from './breadcrumbs/breadcrumb-store'; +export type { MessageProcessor, ProcessingPayload } from './messages/message-processor'; diff --git a/packages/core/src/messages/message-processor.ts b/packages/core/src/messages/message-processor.ts new file mode 100644 index 00000000..817eb97f --- /dev/null +++ b/packages/core/src/messages/message-processor.ts @@ -0,0 +1,43 @@ +import type { CatcherMessagePayload, CatcherMessageType } from '@hawk.so/types'; + +/** + * Extracted addons type from catcher message payload. + * + * @typeParam T - catcher message type + */ +type ExtractAddons = + CatcherMessagePayload extends { addons?: infer A } ? A : never; + +/** + * Payload type used during message processing pipeline. + * + * Same as {@link CatcherMessagePayload} but with `addons` always defined and partially filled — + * processors may contribute individual addon fields independently of each other. + * + * @typeParam T - catcher message type this payload belongs to + */ +export type ProcessingPayload = + Omit, 'addons'> & { + addons: Partial>; + }; + +/** + * Single step in message processing pipeline before message is sent. + * + * @typeParam T - catcher message type this processor handles + */ +export interface MessageProcessor { + /** + * Handles input message. May mutate, replace or drop it. + * + * Dropped message won't be sent. + * + * @param payload - processed event message payload with partially-built addons + * @param error - original error + * @returns modified payload, or `null` to drop message + */ + apply( + payload: ProcessingPayload, + error?: Error | string, + ): ProcessingPayload | null +} diff --git a/packages/core/src/modules/sanitizer.ts b/packages/core/src/modules/sanitizer.ts index ba2488ed..44289824 100644 --- a/packages/core/src/modules/sanitizer.ts +++ b/packages/core/src/modules/sanitizer.ts @@ -154,7 +154,6 @@ export class Sanitizer { depth: number, seen: WeakSet ): Record | '' | '' { - // If the maximum depth is reached, return a placeholder if (depth > Sanitizer.maxDepth) { return ''; diff --git a/packages/javascript/package.json b/packages/javascript/package.json index 7604d994..5b401bfa 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -1,6 +1,6 @@ { "name": "@hawk.so/javascript", - "version": "3.2.20", + "version": "3.2.21", "description": "JavaScript errors tracking for Hawk.so", "files": [ "dist" diff --git a/packages/javascript/src/addons/userAgentInfo.ts b/packages/javascript/src/addons/userAgentInfo.ts deleted file mode 100644 index ec7c3706..00000000 --- a/packages/javascript/src/addons/userAgentInfo.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @file Integration for adding User Agent info - */ - -/** - * @param event - event to modify - * @param data - event data - */ -export default function (event, data): void { - data.payload.userAgent = { - name: window.navigator.userAgent, - frame: { - width: window.innerWidth, - height: window.innerHeight, - }, - }; -} diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 46d98f54..87b834eb 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -4,19 +4,17 @@ import type { CatcherMessage, HawkInitialSettings, HawkJavaScriptEvent, Transpor import { VueIntegration } from './integrations/vue'; import type { AffectedUser, + CatcherMessagePayload, DecodedIntegrationToken, EncodedIntegrationToken, EventContext, - JavaScriptAddons, - Json, VueIntegrationAddons } from '@hawk.so/types'; import type { JavaScriptCatcherIntegrations } from '@/types'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BrowserBreadcrumbStore } from './addons/breadcrumbs'; -import type { BreadcrumbStore } from '@hawk.so/core'; +import type { BreadcrumbStore, MessageProcessor, ProcessingPayload } from '@hawk.so/core'; import { - EventRejectedError, HawkUserManager, isErrorProcessed, isLoggerSet, @@ -32,6 +30,10 @@ import { import { HawkLocalStorage } from './storages/hawk-local-storage'; import { createBrowserLogger } from './logger/logger'; import { BrowserRandomGenerator } from './utils/random'; +import { BrowserAddonMessageProcessor } from './messages/browser-addon-message-processor'; +import { ConsoleOutputAddonMessageProcessor } from './messages/console-output-addon-message-processor'; +import { DebugAddonMessageProcessor } from './messages/debug-addon-message-processor'; +import { BrowserBreadcrumbsMessageProcessor } from './messages/browser-breadcrumbs-message-processor'; /** * Allow to use global VERSION, that will be overwritten by Webpack @@ -124,7 +126,7 @@ export default class Catcher { /** * Breadcrumb store instance */ - private readonly breadcrumbStore: BrowserBreadcrumbStore | null; + private readonly breadcrumbStore: BrowserBreadcrumbStore | null = null; /** * Manages currently authenticated user identity. @@ -134,6 +136,11 @@ export default class Catcher { new BrowserRandomGenerator() ); + /** + * Ordered list of message processors applied to every outgoing event message. + */ + private readonly messageProcessors: MessageProcessor[]; + /** * Catcher constructor * @@ -162,6 +169,9 @@ export default class Catcher { settings.consoleTracking !== null && settings.consoleTracking !== undefined ? settings.consoleTracking : true; + this.messageProcessors = [ + new BrowserAddonMessageProcessor(), + ]; if (!this.token) { log( @@ -189,7 +199,7 @@ export default class Catcher { if (this.consoleTracking) { this.consoleCatcher = ConsoleCatcher.getInstance(); - this.consoleCatcher.init(); + this.messageProcessors.push(new ConsoleOutputAddonMessageProcessor(this.consoleCatcher)); } /** @@ -197,9 +207,15 @@ export default class Catcher { */ if (settings.breadcrumbs !== false) { this.breadcrumbStore = BrowserBreadcrumbStore.getInstance(); - this.breadcrumbStore.init(settings.breadcrumbs ?? {}); - } else { - this.breadcrumbStore = null; + this.messageProcessors.push(new BrowserBreadcrumbsMessageProcessor(settings.breadcrumbs ?? {})); + } + + if (this.debug) { + this.messageProcessors.push(new DebugAddonMessageProcessor()); + } + + if (settings.messageProcessors) { + this.messageProcessors.push(...settings.messageProcessors); } /** @@ -361,7 +377,12 @@ export default class Catcher { } /** - * Format and send an error + * Process and sends error message. + * + * Returns early without sending either if + * - error was already processed, + * - message processor drops it + * - {@link beforeSend} hook rejects it * * @param error - error to send * @param integrationAddons - addons spoiled by Integration @@ -385,105 +406,119 @@ export default class Catcher { markErrorAsProcessed(error); } - const errorFormatted = await this.prepareErrorFormatted(error, context); + let processingPayload = await this.buildBasePayload(error, context); + + for (const processor of this.messageProcessors) { + const result = processor.apply(processingPayload, error); + + if (result === null) { + return; + } + + processingPayload = result; + } + + const payload = processingPayload as CatcherMessagePayload; - /** - * If this event caught by integration (Vue or other), it can pass extra addons - */ if (integrationAddons) { - this.appendIntegrationAddons(errorFormatted, Sanitizer.sanitize(integrationAddons)); + payload.addons = { + ...(payload.addons ?? {}), + ...Sanitizer.sanitize(integrationAddons), + }; } - this.sendErrorFormatted(errorFormatted); - } catch (e) { - if (e instanceof EventRejectedError) { - /** - * Event was rejected by user using the beforeSend method - */ + const payloadPostBeforeSend = this.applyBeforeSendHook(payload); + + if (payloadPostBeforeSend === null) { + // Event was rejected by user using the beforeSend method return; } + this.sendMessage({ + token: this.token, + catcherType: Catcher.type, + payload: payloadPostBeforeSend, + } as CatcherMessage); + } catch (e) { log('Unable to send error. Seems like it is Hawk internal bug. Please, report it here: https://github.com/codex-team/hawk.javascript/issues/new', 'warn', e); } } /** - * Sends formatted HawkEvent to the Collector + * Builds base event payload with basic fields (title, type, backtrace, user, context, release). * - * @param errorFormatted - formatted error to send + * @param error - caught error or string reason + * @param context - per-call context to merge with instance-level context + * @returns base payload with core data */ - private sendErrorFormatted(errorFormatted: CatcherMessage): void { - this.transport.send(errorFormatted) - .catch((sendingError) => { - log('WebSocket sending error', 'error', sendingError); - }); - } - - /** - * Formats the event - * - * @param error - error to format - * @param context - any additional data passed by user - */ - private async prepareErrorFormatted(error: Error | string, context?: EventContext): Promise> { - let payload: HawkJavaScriptEvent = { + private async buildBasePayload( + error: Error | string, + context?: EventContext + ): Promise> { + return { title: this.getTitle(error), type: this.getType(error), release: this.getRelease(), - breadcrumbs: this.getBreadcrumbsForEvent(), context: this.getContext(context), user: this.getUser(), - addons: this.getAddons(error), backtrace: await this.getBacktrace(error), catcherVersion: this.version, + addons: {}, }; + } - /** - * Filter sensitive data - */ - if (typeof this.beforeSend === 'function') { - let eventPayloadClone: HawkJavaScriptEvent; + /** + * Clones {@link payload} and applies user-supplied {@link beforeSend} hook against it. + * + * @param payload - processed event message payload + * @returns possibly modified payload, or null if the event should be dropped + */ + private applyBeforeSendHook( + payload: CatcherMessagePayload + ): CatcherMessagePayload | null { + if (typeof this.beforeSend !== 'function') { + return payload; + } - try { - eventPayloadClone = structuredClone(payload); - } catch { - /** - * structuredClone may fail on non-cloneable values (functions, DOM nodes, etc.) - * Fall back to passing the original — hook may mutate it, but at least reporting won't crash - */ - eventPayloadClone = payload; - } + let clone: CatcherMessagePayload; - const result = this.beforeSend(eventPayloadClone); + try { + clone = structuredClone(payload); + } catch { + // structuredClone may fail on non-cloneable values (functions, DOM nodes, etc.) + // Fall back to passing the original — hook may mutate it, but at least reporting won't crash + clone = payload; + } - /** - * false → drop event - */ - if (result === false) { - throw new EventRejectedError('Event rejected by beforeSend method.'); - } + const result = this.beforeSend(clone); - /** - * Valid event payload → use it instead of original - */ - if (isValidEventPayload(result)) { - payload = result as HawkJavaScriptEvent; - } else { - /** - * Anything else is invalid — warn, payload stays untouched (hook only received a clone) - */ - log( - 'Invalid beforeSend value. It should return event or false. Event is sent without changes.', - 'warn' - ); - } + // false → drop event + if (result === false) { + return null; } - return { - token: this.token, - catcherType: Catcher.type, - payload, - }; + // Valid event payload → use it instead of original + if (isValidEventPayload(result)) { + return result as CatcherMessagePayload; + } + + // Anything else is invalid — warn, payload stays untouched (hook only received a clone) + log( + 'Invalid beforeSend value. It should return event or false. Event is sent without changes.', + 'warn' + ); + + return payload; + } + + /** + * Dispatches assembled message over configured transport. + * + * @param message - fully assembled catcher message ready to send + */ + private sendMessage(message: CatcherMessage): void { + this.transport.send(message) + .catch((e) => log('Transport sending error', 'error', e)); } /** @@ -575,39 +610,6 @@ export default class Catcher { return this.userManager.getUser(); } - /** - * Get breadcrumbs for event payload - */ - private getBreadcrumbsForEvent(): HawkJavaScriptEvent['breadcrumbs'] { - const breadcrumbs = this.breadcrumbStore?.get(); - - return breadcrumbs && breadcrumbs.length > 0 ? breadcrumbs : undefined; - } - - /** - * Get parameters - */ - private getGetParams(): Json | null { - const searchString = window.location.search.substr(1); - - if (!searchString) { - return null; - } - - /** - * Create object from get-params string - */ - const pairs = searchString.split('&'); - - return pairs.reduce((accumulator, pair) => { - const [key, value] = pair.split('='); - - accumulator[key] = value; - - return accumulator; - }, {}); - } - /** * Return parsed backtrace information * @@ -632,70 +634,4 @@ export default class Catcher { return undefined; } } - - /** - * Return some details - * - * @param {Error|string} error — caught error - */ - private getAddons(error: Error | string): HawkJavaScriptEvent['addons'] { - const { innerWidth, innerHeight } = window; - const userAgent = window.navigator.userAgent; - const location = window.location.href; - const getParams = this.getGetParams(); - const consoleLogs = this.consoleTracking && this.consoleCatcher?.getConsoleLogStack(); - - const addons: JavaScriptAddons = { - window: { - innerWidth, - innerHeight, - }, - userAgent, - url: location, - }; - - if (getParams) { - addons.get = getParams; - } - - if (this.debug) { - addons.RAW_EVENT_DATA = this.getRawData(error); - } - - if (consoleLogs && consoleLogs.length > 0) { - addons.consoleOutput = consoleLogs; - } - - return addons; - } - - /** - * Compose raw data object - * - * @param {Error|string} error — caught error - */ - private getRawData(error: Error | string): Json | undefined { - if (!(error instanceof Error)) { - return; - } - - const stack = error.stack !== null && error.stack !== undefined ? error.stack : ''; - - return { - name: error.name, - message: error.message, - stack, - }; - } - - /** - * Extend addons object with addons spoiled by integration - * This method mutates original event - * - * @param errorFormatted - Hawk event prepared for sending - * @param integrationAddons - extra addons - */ - private appendIntegrationAddons(errorFormatted: CatcherMessage, integrationAddons: JavaScriptCatcherIntegrations): void { - Object.assign(errorFormatted.payload.addons, integrationAddons); - } } diff --git a/packages/javascript/src/messages/browser-addon-message-processor.ts b/packages/javascript/src/messages/browser-addon-message-processor.ts new file mode 100644 index 00000000..28f66902 --- /dev/null +++ b/packages/javascript/src/messages/browser-addon-message-processor.ts @@ -0,0 +1,61 @@ +import type { MessageProcessor, ProcessingPayload } from '@hawk.so/core'; +import { type Json } from '@hawk.so/types'; + +/** + * Enriches payload with browser environment data: + * viewport dimensions, user agent, current URL, and GET parameters. + */ +export class BrowserAddonMessageProcessor implements MessageProcessor<'errors/javascript'> { + /** + * Reads current browser state (window dimensions, user agent, URL, GET params) and + * merges it into `payload.addons`, preserving any addons already set. + * + * @param payload - event message payload to enrich + * @returns modified payload with browser addons merged in + */ + public apply( + payload: ProcessingPayload<'errors/javascript'> + ): ProcessingPayload<'errors/javascript'> | null { + const { innerWidth, innerHeight } = window; + const userAgent = window.navigator.userAgent; + const url = window.location.href; + const get = this.parseGetParams(); + + payload.addons = { + ...payload.addons, + window: { + innerWidth, + innerHeight, + }, + userAgent, + url, + get, + }; + + return payload; + } + + /** + * Parses `window.location.search` into plain key-value object. + * + * @returns parsed GET parameters, or `undefined` if URL has no query string + */ + private parseGetParams(): Json | undefined { + const searchString = window.location.search.substring(1); + + if (!searchString) { + return undefined; + } + + // Create object from get-params string + const pairs = searchString.split('&'); + + return pairs.reduce((accumulator, pair) => { + const [key, value] = pair.split('='); + + accumulator[key] = value; + + return accumulator; + }, {}); + } +} diff --git a/packages/javascript/src/messages/browser-breadcrumbs-message-processor.ts b/packages/javascript/src/messages/browser-breadcrumbs-message-processor.ts new file mode 100644 index 00000000..e8f75a4d --- /dev/null +++ b/packages/javascript/src/messages/browser-breadcrumbs-message-processor.ts @@ -0,0 +1,38 @@ +import type { MessageProcessor, ProcessingPayload } from '@hawk.so/core'; +import type { BreadcrumbsOptions } from '../addons/breadcrumbs'; +import { BrowserBreadcrumbStore } from '../addons/breadcrumbs'; +import type { ErrorsCatcherType } from '@hawk.so/types/src/catchers/catcher-message'; + +/** + * Attaches breadcrumbs to payload. + */ +export class BrowserBreadcrumbsMessageProcessor implements MessageProcessor { + /** + * Initialize message processor including {@link BrowserBreadcrumbStore} initialization. + * + * @param options {@link BrowserBreadcrumbStore} settings required for initialization. + */ + constructor(options: BreadcrumbsOptions = {}) { + const breadcrumbStore = BrowserBreadcrumbStore.getInstance(); + + breadcrumbStore.init(options); + } + + /** + * Sets `payload.breadcrumbs` from snapshot if non-empty; skips otherwise. + * + * @param payload - event message payload to enrich + * @returns modified payload with breadcrumbs set, or original payload unchanged + */ + public apply( + payload: ProcessingPayload + ): ProcessingPayload | null { + const breadcrumbs = BrowserBreadcrumbStore.getInstance().get(); + + if (breadcrumbs.length > 0) { + payload.breadcrumbs = breadcrumbs; + } + + return payload; + } +} diff --git a/packages/javascript/src/messages/console-output-addon-message-processor.ts b/packages/javascript/src/messages/console-output-addon-message-processor.ts new file mode 100644 index 00000000..36e1e937 --- /dev/null +++ b/packages/javascript/src/messages/console-output-addon-message-processor.ts @@ -0,0 +1,38 @@ +import type { MessageProcessor, ProcessingPayload } from '@hawk.so/core'; +import type { ConsoleCatcher } from '../addons/consoleCatcher'; + +/** + * Attaches captured console output to payload addons. + */ +export class ConsoleOutputAddonMessageProcessor implements MessageProcessor<'errors/javascript'> { + private readonly consoleCatcher: ConsoleCatcher; + + /** + * Stores catcher reference and starts console interception. + * + * @param consoleCatcher - console catcher instance to read logs from + */ + constructor(consoleCatcher: ConsoleCatcher) { + this.consoleCatcher = consoleCatcher; + this.consoleCatcher.init(); + } + + /** + * Attaches current console log stack to `payload.addons.consoleOutput`. + * Skips if log stack is empty. + * + * @param payload - event message payload to enrich + * @returns modified payload with console logs attached, or original payload unchanged + */ + public apply( + payload: ProcessingPayload<'errors/javascript'> + ): ProcessingPayload<'errors/javascript'> | null { + const logs = this.consoleCatcher?.getConsoleLogStack(); + + if (logs && logs.length > 0) { + payload.addons.consoleOutput = logs; + } + + return payload; + } +} diff --git a/packages/javascript/src/messages/debug-addon-message-processor.ts b/packages/javascript/src/messages/debug-addon-message-processor.ts new file mode 100644 index 00000000..9b0694be --- /dev/null +++ b/packages/javascript/src/messages/debug-addon-message-processor.ts @@ -0,0 +1,31 @@ +import type { MessageProcessor, ProcessingPayload } from '@hawk.so/core'; + +/** + * Appends `RAW_EVENT_DATA` to the event addons for debug purposes. + */ +export class DebugAddonMessageProcessor implements MessageProcessor<'errors/javascript'> { + /** + * Writes name, message, and stack from `snapshot.error` into `payload.addons.RAW_EVENT_DATA`. + * Skips if snapshot error is missing or not Error instance. + * + * @param payload - event message payload to enrich + * @param error - original error + * @returns modified payload with RAW_EVENT_DATA set, or original payload unchanged + */ + public apply( + payload: ProcessingPayload<'errors/javascript'>, + error?: Error | string + ): ProcessingPayload<'errors/javascript'> | null { + if (!(error instanceof Error)) { + return payload; + } + + payload.addons.RAW_EVENT_DATA = { + name: error.name, + message: error.message, + stack: error.stack ?? '', + }; + + return payload; + } +} diff --git a/packages/javascript/src/types/hawk-initial-settings.ts b/packages/javascript/src/types/hawk-initial-settings.ts index 7293826f..cf60fed6 100644 --- a/packages/javascript/src/types/hawk-initial-settings.ts +++ b/packages/javascript/src/types/hawk-initial-settings.ts @@ -1,6 +1,6 @@ import type { AffectedUser, EventContext } from '@hawk.so/types'; import type { HawkJavaScriptEvent } from './event'; -import type { Transport } from '@hawk.so/core'; +import type { MessageProcessor, Transport } from '@hawk.so/core'; import type { BreadcrumbsOptions } from '../addons/breadcrumbs'; /** @@ -98,4 +98,11 @@ export interface HawkInitialSettings { * If not provided, default WebSocket transport is used. */ transport?: Transport; + + /** + * Custom message processors. + * Used to prepare event message before send. + * May modify original event payload or return null to drop it. + */ + messageProcessors?: MessageProcessor<'errors/javascript'>[]; } diff --git a/packages/javascript/tests/catcher.breadcrumbs.test.ts b/packages/javascript/tests/catcher.breadcrumbs.test.ts index b0d28245..92a3a4cb 100644 --- a/packages/javascript/tests/catcher.breadcrumbs.test.ts +++ b/packages/javascript/tests/catcher.breadcrumbs.test.ts @@ -21,7 +21,7 @@ describe('Catcher', () => { describe('breadcrumbs trail', () => { it('should include recorded breadcrumbs', async () => { const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport, { breadcrumbs: {} }); + const hawk = createCatcher(transport, { breadcrumbs: { trackFetch: false } }); hawk.breadcrumbs.add({ message: 'button clicked', timestamp: Date.now() }); hawk.send(new Error('e')); @@ -36,7 +36,7 @@ describe('Catcher', () => { it('should omit breadcrumbs when none have been recorded', async () => { const { sendSpy, transport } = createTransport(); - createCatcher(transport, { breadcrumbs: {} }).send(new Error('e')); + createCatcher(transport, { breadcrumbs: { trackFetch: false } }).send(new Error('e')); await wait(); expect(getLastPayload(sendSpy).breadcrumbs).toBeFalsy(); @@ -50,7 +50,7 @@ describe('Catcher', () => { it('should omit breadcrumbs cleared before payload was sent', async () => { const { sendSpy, transport } = createTransport(); - const hawk = createCatcher(transport, { breadcrumbs: {} }); + const hawk = createCatcher(transport, { breadcrumbs: { trackFetch: false } }); hawk.breadcrumbs.add({ message: 'click', timestamp: Date.now() }); hawk.breadcrumbs.clear(); diff --git a/packages/javascript/tests/catcher.message-processor.ts b/packages/javascript/tests/catcher.message-processor.ts new file mode 100644 index 00000000..2cd25e71 --- /dev/null +++ b/packages/javascript/tests/catcher.message-processor.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createCatcher, createTransport, getLastPayload, wait } from './catcher.helpers'; +import { MessageProcessor, ProcessingPayload } from '@hawk.so/core'; + +const mockParse = vi.hoisted(() => vi.fn().mockResolvedValue([])); +vi.mock('../src/modules/stackParser', () => ({ + default: class { + parse = mockParse; + } +})); + +describe('Catcher', () => { + describe('message processor', () => { + it('should send original message when processor does not modify it', async () => { + const { sendSpy, transport } = createTransport(); + const applySpy = vi.fn((payload: ProcessingPayload<'errors/javascript'>) => payload); + const processor: MessageProcessor<'errors/javascript'> = { apply: applySpy }; + const hawk = createCatcher(transport, { messageProcessors: [processor] }); + + hawk.send('original message'); + await wait(); + + expect(applySpy).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(getLastPayload(sendSpy).message).toBe('original message'); + }); + + it('should send modified message when processor modify it', async () => { + const { sendSpy, transport } = createTransport(); + const applySpy = vi.fn((payload: ProcessingPayload<'errors/javascript'>) => { + return { + ...payload, + message: 'modified message', + }; + }); + const processor: MessageProcessor<'errors/javascript'> = { apply: applySpy, }; + const hawk = createCatcher(transport, { messageProcessors: [processor], }); + + hawk.send('original message'); + await wait(); + + expect(applySpy).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(getLastPayload(sendSpy).message).toBe('modified message'); + }); + + it('should drop message when processor returns null', async () => { + const { sendSpy, transport } = createTransport(); + const applySpy = vi.fn(() => null); + const processor: MessageProcessor<'errors/javascript'> = { apply: applySpy, }; + const hawk = createCatcher(transport, { messageProcessors: [processor], }); + + hawk.send('test error'); + await wait(); + + expect(applySpy).toHaveBeenCalledTimes(1); + expect(sendSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/javascript/tests/messages/breadcrumbs-message-processor.test.ts b/packages/javascript/tests/messages/breadcrumbs-message-processor.test.ts new file mode 100644 index 00000000..c7923716 --- /dev/null +++ b/packages/javascript/tests/messages/breadcrumbs-message-processor.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { makePayload } from './message-processor.helpers'; +import { BrowserBreadcrumbsMessageProcessor } from '../../src/messages/browser-breadcrumbs-message-processor'; +import { BrowserBreadcrumbStore } from '../../src/addons/breadcrumbs'; + +describe('BreadcrumbsMessageProcessor', () => { + const processor = new BrowserBreadcrumbsMessageProcessor(); + + beforeEach(() => { + BrowserBreadcrumbStore.getInstance().clear() + }); + + it('should attach breadcrumbs from snapshot to payload', () => { + const breadcrumbs = { message: 'click', timestamp: 1 }; + BrowserBreadcrumbStore.getInstance().add(breadcrumbs) + + const result = processor.apply(makePayload()); + + expect(result?.breadcrumbs).toHaveLength(1) + expect(result?.breadcrumbs).toContainEqual(breadcrumbs); + }); + + it('should not set payload breadcrumbs when breadcrumb store is empty', () => { + const result = processor.apply(makePayload()); + + expect(result?.breadcrumbs).toBeUndefined(); + }); +}); diff --git a/packages/javascript/tests/messages/browser-message-processor.test.ts b/packages/javascript/tests/messages/browser-message-processor.test.ts new file mode 100644 index 00000000..9eff0202 --- /dev/null +++ b/packages/javascript/tests/messages/browser-message-processor.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { BrowserAddonMessageProcessor } from '../../src/messages/browser-addon-message-processor'; +import { makePayload } from './message-processor.helpers'; + +describe('BrowserMessageProcessor', () => { + beforeEach(() => { + vi.stubGlobal('location', { href: 'http://localhost/', search: '' }); + vi.stubGlobal('navigator', { userAgent: 'test-agent' }); + vi.stubGlobal('innerWidth', 1280); + vi.stubGlobal('innerHeight', 720); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should set window dimensions, userAgent, and url', () => { + const result = new BrowserAddonMessageProcessor().apply(makePayload()); + + expect(result?.addons).toMatchObject({ + window: { innerWidth: 1280, innerHeight: 720 }, + userAgent: 'test-agent', + url: 'http://localhost/', + }); + }); + + it('should parse GET parameters from the URL', () => { + vi.stubGlobal('location', { href: 'http://localhost/?foo=bar&baz=qux', search: '?foo=bar&baz=qux' }); + + const result = new BrowserAddonMessageProcessor().apply(makePayload()); + + expect(result?.addons?.get).toEqual({ foo: 'bar', baz: 'qux' }); + }); + + it('should omit get when URL has no query string', () => { + const result = new BrowserAddonMessageProcessor().apply(makePayload()); + + expect(result?.addons?.get).toBeUndefined(); + }); + + it('should merge with existing payload addons', () => { + const payload = makePayload({ addons: { consoleOutput: [{ message: 'log' }] } }); + const result = new BrowserAddonMessageProcessor().apply(payload); + + expect(result?.addons?.consoleOutput).toBeDefined(); + expect(result?.addons?.userAgent).toBeDefined(); + }); +}); diff --git a/packages/javascript/tests/messages/console-catcher-message-processor.test.ts b/packages/javascript/tests/messages/console-catcher-message-processor.test.ts new file mode 100644 index 00000000..c7abea43 --- /dev/null +++ b/packages/javascript/tests/messages/console-catcher-message-processor.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ConsoleOutputAddonMessageProcessor } from '../../src/messages/console-output-addon-message-processor'; +import { makePayload } from './message-processor.helpers'; + +const makeConsoleCatcher = (logs: any[]) => ({ + init: vi.fn(), + getConsoleLogStack: vi.fn().mockReturnValue(logs), + addErrorEvent: vi.fn(), +}); + +describe('ConsoleCatcherMessageProcessor', () => { + it('should attach console logs to payload addons', () => { + const logs = [{ message: 'hello', type: 'log' }]; + const processor = new ConsoleOutputAddonMessageProcessor(makeConsoleCatcher(logs) as any); + + const result = processor.apply(makePayload()); + + expect(result?.addons?.consoleOutput).toEqual(logs); + }); + + it('should not add consoleOutput when log stack is empty', () => { + const processor = new ConsoleOutputAddonMessageProcessor(makeConsoleCatcher([]) as any); + + const result = processor.apply(makePayload()); + + expect(result?.addons?.consoleOutput).toBeUndefined(); + }); +}); diff --git a/packages/javascript/tests/messages/debug-message-processor.test.ts b/packages/javascript/tests/messages/debug-message-processor.test.ts new file mode 100644 index 00000000..29f85932 --- /dev/null +++ b/packages/javascript/tests/messages/debug-message-processor.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { DebugAddonMessageProcessor } from '../../src/messages/debug-addon-message-processor'; +import { makePayload } from './message-processor.helpers'; + +describe('DebugMessageProcessor', () => { + const processor = new DebugAddonMessageProcessor(); + + it('should add RAW_EVENT_DATA when hint.error is an Error instance', () => { + const error = new TypeError('boom'); + + const result = processor.apply(makePayload(), error); + + expect(result?.addons?.RAW_EVENT_DATA).toMatchObject({ + name: 'TypeError', + message: 'boom', + stack: expect.any(String), + }); + }); + + it('should not add RAW_EVENT_DATA when hint.error is a string', () => { + const result = processor.apply(makePayload(), 'string reason'); + + expect(result?.addons?.RAW_EVENT_DATA).toBeUndefined(); + }); + + it('should not add RAW_EVENT_DATA when hint is absent', () => { + const result = processor.apply(makePayload()); + + expect(result?.addons?.RAW_EVENT_DATA).toBeUndefined(); + }); +}); diff --git a/packages/javascript/tests/messages/message-processor.helpers.ts b/packages/javascript/tests/messages/message-processor.helpers.ts new file mode 100644 index 00000000..ce654525 --- /dev/null +++ b/packages/javascript/tests/messages/message-processor.helpers.ts @@ -0,0 +1,7 @@ +import type { ProcessingPayload } from '@hawk.so/core'; + +export function makePayload( + overrides: Partial> = {} +): ProcessingPayload<'errors/javascript'> { + return { title: 'Test error', catcherVersion: '0.0.0', addons: {}, ...overrides }; +} From 465508d6f0fff1aca339ad7946058cd08242bc4b Mon Sep 17 00:00:00 2001 From: Reversean Date: Wed, 8 Apr 2026 12:12:57 +0300 Subject: [PATCH 3/4] 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. --- packages/core/src/catcher.ts | 458 +++++++++++++++++++++++++ packages/core/src/index.ts | 2 + packages/javascript/package.json | 2 +- packages/javascript/src/catcher.ts | 521 +++++------------------------ 4 files changed, 540 insertions(+), 443 deletions(-) create mode 100644 packages/core/src/catcher.ts diff --git a/packages/core/src/catcher.ts b/packages/core/src/catcher.ts new file mode 100644 index 00000000..b252e439 --- /dev/null +++ b/packages/core/src/catcher.ts @@ -0,0 +1,458 @@ +import type { + AffectedUser, + BacktraceFrame, + CatcherMessage, + CatcherMessagePayload, + CatcherMessageType, + EncodedIntegrationToken, + EventContext +} from '@hawk.so/types'; +import type { ErrorsCatcherType } from '@hawk.so/types/src/catchers/catcher-message'; +import type { Transport } from './transports/transport'; +import type { BreadcrumbStore } from './breadcrumbs/breadcrumb-store'; +import type { MessageProcessor, ProcessingPayload } from './messages/message-processor'; +import { StackParser } from './modules/stack-parser'; +import type { HawkUserManager } from './users/hawk-user-manager'; +import { validateContext, validateUser, isValidEventPayload } from './utils/validation'; +import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; +import { Sanitizer } from './modules/sanitizer'; +import { log } from './logger/logger'; + +/** + * User-supplied hook to filter or modify events before sending. + * - Return modified event — it will be sent instead of the original. + * - Return `false` — the event will be dropped entirely. + * - Any other value is invalid — the original event is sent as-is (a warning is logged). + * + * @typeParam T - catcher message type + */ +export type BeforeSendHook = (event: CatcherMessagePayload) => CatcherMessagePayload | false | void; + +/** + * Abstract base class for all Hawk catchers. + * + * Contains env-agnostic logic for sending captured error events and managing related context. + * + * **Transport** — used to deliver * assembled {@link CatcherMessage} objects to Collector. + * Provided via constructor. + * + * **User manager** — {@link HawkUserManager} resolves current affected user. + * Provided via constructor so each environment can supply its own storage backend. + * + * **Breadcrumb store** — optional {@link BreadcrumbStore} passed via constructor. + * + * **Message processors** — pipeline of {@link MessageProcessor} instances + * applied to every outgoing event. Environment-specific processors may be provided + * via {@link addMessageProcessor}. + * + * Each {@link formatAndSend} call initiates **sending pipeline** which consist of following steps: + * - base payload is built, + * - sequentially apply message processors to payload + * - apply optional {@link BeforeSendHook}, + * - dispatch message via {@link Transport}. + * + * Subclasses must implement {@link getCatcherType} and {@link getCatcherVersion} + * (they are used for building base payload during sending pipeline). + * + * @typeParam T - catcher message type this catcher handles + */ +export abstract class BaseCatcher { + /** + * Integration token used to identify the project + */ + private readonly token: EncodedIntegrationToken; + + /** + * Transport for dialog between Catcher and Collector + */ + private readonly transport: Transport; + + /** + * Manages currently authenticated user identity + */ + private readonly userManager: HawkUserManager; + + /** + * Any additional data passed by user for sending with all messages + */ + private context?: EventContext; + + /** + * Current bundle version + */ + private readonly release?: string; + + /** + * This method allows developer to filter any data you don't want sending to Hawk. + * - Return modified event — it will be sent instead of the original. + * - Return `false` — the event will be dropped entirely. + * - Any other value is invalid — the original event is sent as-is (a warning is logged). + */ + private readonly beforeSend?: BeforeSendHook; + + /** + * Breadcrumb store instance + */ + private readonly breadcrumbStore?: BreadcrumbStore; + + /** + * List of message processors applied to every outgoing event message. + */ + private readonly messageProcessors: MessageProcessor[] = []; + + /** + * Module for parsing backtrace + */ + private readonly stackParser: StackParser = new StackParser(); + + /** + * @param token - encoded integration token identifying the project + * @param transport - transport used to deliver events to Collector + * @param userManager - manages current affected user identity + * @param release - optional bundle release version attached to every event + * @param context - optional global context merged into every event + * @param beforeSend - optional hook to filter or modify events before sending + * @param breadcrumbStore - optional breadcrumb store + */ + protected constructor( + token: EncodedIntegrationToken, + transport: Transport, + userManager: HawkUserManager, + release?: string, + context?: EventContext, + beforeSend?: BeforeSendHook, + breadcrumbStore?: BreadcrumbStore + ) { + this.token = token; + this.transport = transport; + this.userManager = userManager; + this.release = release; + this.beforeSend = beforeSend; + this.breadcrumbStore = breadcrumbStore; + this.setContext(context); + } + + /** + * Send test event from client + */ + public test(): void { + this.send(new Error('Hawk JavaScript Catcher test message.')); + } + + /** + * Public method for manual sending messages to the Hawk. + * Can be called in user's try-catch blocks or by other custom logic. + * + * @param message - what to send + * @param context - any additional data to send + */ + public send(message: Error | string, context?: EventContext): void { + void this.formatAndSend(message, undefined, context); + } + + /** + * Update the current user information + * + * @param user - New user information + */ + public setUser(user: AffectedUser): void { + if (!validateUser(user)) { + return; + } + + this.userManager.setUser(user); + } + + /** + * Clear current user information + */ + public clearUser(): void { + this.userManager.clear(); + } + + /** + * Update the context data that will be sent with all events + * + * @param context - New context data + */ + public setContext(context: EventContext | undefined): void { + if (!validateContext(context)) { + return; + } + + this.context = context; + } + + /** + * Breadcrumbs API - provides convenient access to breadcrumb methods + * + * @example + * hawk.breadcrumbs.add({ + * type: 'user', + * category: 'auth', + * message: 'User logged in', + * level: 'info', + * data: { userId: '123' } + * }); + */ + public get breadcrumbs(): BreadcrumbStore { + return { + add: (breadcrumb, hint) => this.breadcrumbStore?.add(breadcrumb, hint), + get: () => this.breadcrumbStore?.get() ?? [], + clear: () => this.breadcrumbStore?.clear(), + }; + } + + /** + * Add message processor to the pipeline. + * + * @param processors - processors to add + */ + protected addMessageProcessor(...processors: MessageProcessor[]): void { + this.messageProcessors.push(...processors); + } + + /** + * Process and sends error message. + * + * Returns early without sending if: + * - error was already processed, + * - a message processor drops it, + * - {@link beforeSend} hook rejects it. + * + * @param error - error to send + * @param integrationAddons - addons passed by integration (e.g. Vue, Nuxt) + * @param context - any additional data passed by user + */ + protected async formatAndSend( + error: Error | string, + integrationAddons?: Record, + context?: EventContext + ): Promise { + try { + if (isErrorProcessed(error)) { + return; + } + + markErrorAsProcessed(error); + + let processingPayload = await this.buildBasePayload(error, context); + + for (const processor of this.messageProcessors) { + const result = processor.apply(processingPayload, error); + + if (result === null) { + // Event was rejected by user using the beforeSend method + return; + } + + processingPayload = result; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const payload = processingPayload as any as CatcherMessagePayload; + + if (integrationAddons) { + payload.addons = { + ...(payload.addons ?? {}), + ...Sanitizer.sanitize(integrationAddons), + }; + } + + const filtered = this.applyBeforeSendHook(payload); + + if (filtered === null) { + return; + } + + this.sendMessage({ + token: this.token, + catcherType: this.getCatcherType(), + payload: filtered, + } as CatcherMessage); + } catch (e) { + log('Unable to send error. Seems like it is Hawk internal bug. Please, report it here: https://github.com/codex-team/hawk.javascript/issues/new', 'warn', e); + } + } + + /** + * Builds base event payload with core fields (title, type, backtrace, user, context, release). + * + * @param error - caught error or string reason + * @param context - per-call context to merge with instance-level context + * @returns base payload with core data + */ + private async buildBasePayload( + error: Error | string, + context?: EventContext + ): Promise> { + return { + title: this.getTitle(error), + type: this.getType(error), + release: this.getRelease(), + context: this.getContext(context), + user: this.getUser(), + backtrace: await this.getBacktrace(error), + catcherVersion: this.getCatcherVersion(), + addons: {}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as unknown as ProcessingPayload; + } + + /** + * Clones payload and applies user-supplied {@link beforeSend} hook against it. + * + * @param payload - processed event message payload + * @returns possibly modified payload, or null if the event should be dropped + */ + private applyBeforeSendHook( + payload: CatcherMessagePayload + ): CatcherMessagePayload | null { + if (typeof this.beforeSend !== 'function') { + return payload; + } + + let clone: CatcherMessagePayload; + + try { + clone = structuredClone(payload); + } catch { + // structuredClone may fail on non-cloneable values (functions, DOM nodes, etc.) + // Fall back to passing the original — hook may mutate it, but at least reporting won't crash + clone = payload; + } + + const result = this.beforeSend(clone); + + // false → drop event + if (result === false) { + return null; + } + + // Valid event payload → use it instead of original + if (isValidEventPayload(result)) { + return result as CatcherMessagePayload; + } + + // Anything else is invalid — warn, payload stays untouched (hook only received a clone) + log( + 'Invalid beforeSend value. It should return event or false. Event is sent without changes.', + 'warn' + ); + + return payload; + } + + /** + * Dispatches assembled message over configured transport. + * + * @param message - fully assembled catcher message ready to send + */ + private sendMessage(message: CatcherMessage): void { + this.transport.send(message) + .catch((e) => log('Transport sending error', 'error', e)); + } + + /** + * Return event title. + * + * @param error - event from which to get the title + */ + private getTitle(error: Error | string): string { + const notAnError = !(error instanceof Error); + + // Case when error is 'reason' of PromiseRejectionEvent + // and reject() provided with text reason instead of Error() + if (notAnError) { + return error.toString(); + } + + return error.message; + } + + /** + * Return event type: TypeError, ReferenceError etc. + * + * @param error - caught error + */ + private getType(error: Error | string): string | undefined { + const notAnError = !(error instanceof Error); + + // Case when error is 'reason' of PromiseRejectionEvent + // and reject() provided with text reason instead of Error() + if (notAnError) { + return undefined; + } + + return error.name; + } + + /** + * Release version + */ + private getRelease(): string | undefined { + return this.release; + } + + /** + * Collects additional information. + * + * @param context - any additional data passed by user + */ + private getContext(context?: EventContext): EventContext | undefined { + const contextMerged = {}; + + if (this.context !== undefined) { + Object.assign(contextMerged, this.context); + } + + if (context !== undefined) { + Object.assign(contextMerged, context); + } + + return Sanitizer.sanitize(contextMerged); + } + + /** + * Returns the current user if set, otherwise generates and persists an anonymous ID. + */ + private getUser(): AffectedUser { + return this.userManager.getUser(); + } + + /** + * Return parsed backtrace information. + * + * @param error - event from which to get backtrace + */ + private async getBacktrace(error: Error | string): Promise { + const notAnError = !(error instanceof Error); + + + // Case when error is 'reason' of PromiseRejectionEvent + // and reject() provided with text reason instead of Error() + if (notAnError) { + return undefined; + } + + try { + return await this.stackParser.parse(error); + } catch (e) { + log('Can not parse stack:', 'warn', e); + + return undefined; + } + } + + /** + * Returns the catcher type identifier. + * + * @example 'errors/javascript' + */ + protected abstract getCatcherType(): T; + + /** + * Returns the catcher version string. + */ + protected abstract getCatcherVersion(): string; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4b05aaf9..0b9eccc6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,3 +13,5 @@ export { buildElementSelector } from './utils/selector'; export { isErrorProcessed, markErrorAsProcessed } from './utils/event'; export type { BreadcrumbStore, BreadcrumbsAPI, BreadcrumbHint, BreadcrumbInput } from './breadcrumbs/breadcrumb-store'; export type { MessageProcessor, ProcessingPayload } from './messages/message-processor'; +export { BaseCatcher } from './catcher'; +export type { BeforeSendHook } from './catcher'; diff --git a/packages/javascript/package.json b/packages/javascript/package.json index 5b401bfa..c3e0d130 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -1,6 +1,6 @@ { "name": "@hawk.so/javascript", - "version": "3.2.21", + "version": "3.2.22", "description": "JavaScript errors tracking for Hawk.so", "files": [ "dist" diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 87b834eb..5ae8febb 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -1,32 +1,12 @@ import './modules/element-sanitizer'; import Socket from './modules/socket'; -import type { CatcherMessage, HawkInitialSettings, HawkJavaScriptEvent, Transport } from './types'; +import type { HawkInitialSettings } from './types'; import { VueIntegration } from './integrations/vue'; -import type { - AffectedUser, - CatcherMessagePayload, - DecodedIntegrationToken, - EncodedIntegrationToken, - EventContext, - VueIntegrationAddons -} from '@hawk.so/types'; +import type { DecodedIntegrationToken, EncodedIntegrationToken, VueIntegrationAddons } from '@hawk.so/types'; import type { JavaScriptCatcherIntegrations } from '@/types'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BrowserBreadcrumbStore } from './addons/breadcrumbs'; -import type { BreadcrumbStore, MessageProcessor, ProcessingPayload } from '@hawk.so/core'; -import { - HawkUserManager, - isErrorProcessed, - isLoggerSet, - isValidEventPayload, - log, - markErrorAsProcessed, - Sanitizer, - setLogger, - StackParser, - validateContext, - validateUser -} from '@hawk.so/core'; +import { BaseCatcher, HawkUserManager, isLoggerSet, log, setLogger } from '@hawk.so/core'; import { HawkLocalStorage } from './storages/hawk-local-storage'; import { createBrowserLogger } from './logger/logger'; import { BrowserRandomGenerator } from './utils/random'; @@ -53,7 +33,7 @@ if (!isLoggerSet()) { * * @copyright CodeX */ -export default class Catcher { +export default class Catcher extends BaseCatcher { /** * JS Catcher version */ @@ -69,45 +49,11 @@ export default class Catcher { */ private static readonly type = 'errors/javascript' as const; - /** - * User project's Integration Token - */ - private readonly token: EncodedIntegrationToken; - /** * Enable debug mode */ private readonly debug: boolean; - /** - * Current bundle version - */ - private readonly release: string | undefined; - - /** - * Any additional data passed by user for sending with all messages - */ - private context: EventContext | undefined; - - /** - * This Method allows developer to filter any data you don't want sending to Hawk. - * - Return modified event — it will be sent instead of the original. - * - Return `false` — the event will be dropped entirely. - * - Any other value is invalid — the original event is sent as-is (a warning is logged). - */ - private readonly beforeSend: undefined | ((event: HawkJavaScriptEvent) => HawkJavaScriptEvent | false | void); - - /** - * Transport for dialog between Catcher and Collector - * (WebSocket decorator by default, or custom via settings.transport) - */ - private readonly transport: Transport; - - /** - * Module for parsing backtrace - */ - private readonly stackParser: StackParser = new StackParser(); - /** * Disable Vue.js error handler */ @@ -123,24 +69,6 @@ export default class Catcher { */ private readonly consoleCatcher: ConsoleCatcher | null = null; - /** - * Breadcrumb store instance - */ - private readonly breadcrumbStore: BrowserBreadcrumbStore | null = null; - - /** - * Manages currently authenticated user identity. - */ - private readonly userManager: HawkUserManager = new HawkUserManager( - new HawkLocalStorage(), - new BrowserRandomGenerator() - ); - - /** - * Ordered list of message processors applied to every outgoing event message. - */ - private readonly messageProcessors: MessageProcessor[]; - /** * Catcher constructor * @@ -153,14 +81,48 @@ export default class Catcher { } as HawkInitialSettings; } - this.token = settings.token; + const token = settings.token; + const userManager = new HawkUserManager( + new HawkLocalStorage(), + new BrowserRandomGenerator() + ); + + // Init transport + // WebSocket decorator by default, or custom via {@link settings.transport} + // No-op when token is missing + const transport = !token + ? { send: (): Promise => Promise.resolve() } + : settings.transport ?? new Socket({ + collectorEndpoint: settings.collectorEndpoint || `wss://${Catcher.decodeIntegrationId(token)}.k1.hawk.so:443/ws`, + reconnectionAttempts: settings.reconnectionAttempts, + reconnectionTimeout: settings.reconnectionTimeout, + onClose(): void { + log( + 'Connection lost. Connection will be restored when new errors occurred', + 'info' + ); + }, + }); + + let breadcrumbStore: BrowserBreadcrumbStore | null = null; + if (token && settings.breadcrumbs !== false) { + breadcrumbStore = BrowserBreadcrumbStore.getInstance(); + } + + super( + token, + transport, + userManager, + settings.release !== undefined ? String(settings.release) : undefined, + settings.context || undefined, + settings.beforeSend, + breadcrumbStore ?? undefined + ); + this.debug = settings.debug || false; - this.release = settings.release !== undefined ? String(settings.release) : undefined; if (settings.user) { this.setUser(settings.user); } - this.setContext(settings.context || undefined); - this.beforeSend = settings.beforeSend; this.disableVueErrorHandler = settings.disableVueErrorHandler !== null && settings.disableVueErrorHandler !== undefined ? settings.disableVueErrorHandler @@ -169,11 +131,9 @@ export default class Catcher { settings.consoleTracking !== null && settings.consoleTracking !== undefined ? settings.consoleTracking : true; - this.messageProcessors = [ - new BrowserAddonMessageProcessor(), - ]; - if (!this.token) { + + if (!token) { log( 'Integration Token is missed. You can get it on https://hawk.so at Project Settings.', 'warn' @@ -182,45 +142,27 @@ export default class Catcher { return; } - /** - * Init transport - */ - this.transport = settings.transport ?? new Socket({ - collectorEndpoint: settings.collectorEndpoint || `wss://${this.getIntegrationId()}.k1.hawk.so:443/ws`, - reconnectionAttempts: settings.reconnectionAttempts, - reconnectionTimeout: settings.reconnectionTimeout, - onClose(): void { - log( - 'Connection lost. Connection will be restored when new errors occurred', - 'info' - ); - }, - }); + this.addMessageProcessor(new BrowserAddonMessageProcessor()); if (this.consoleTracking) { this.consoleCatcher = ConsoleCatcher.getInstance(); - this.messageProcessors.push(new ConsoleOutputAddonMessageProcessor(this.consoleCatcher)); + this.addMessageProcessor(new ConsoleOutputAddonMessageProcessor(this.consoleCatcher)); } - /** - * Initialize breadcrumbs - */ + // Initialize breadcrumbs if (settings.breadcrumbs !== false) { - this.breadcrumbStore = BrowserBreadcrumbStore.getInstance(); - this.messageProcessors.push(new BrowserBreadcrumbsMessageProcessor(settings.breadcrumbs ?? {})); + this.addMessageProcessor(new BrowserBreadcrumbsMessageProcessor(settings.breadcrumbs ?? {})); } if (this.debug) { - this.messageProcessors.push(new DebugAddonMessageProcessor()); + this.addMessageProcessor(new DebugAddonMessageProcessor()); } if (settings.messageProcessors) { - this.messageProcessors.push(...settings.messageProcessors); + this.addMessageProcessor(...settings.messageProcessors); } - /** - * Set global handlers - */ + // Set global handlers if (!settings.disableGlobalErrorsHandling) { this.initGlobalHandlers(); } @@ -231,23 +173,23 @@ export default class Catcher { } /** - * Send test event from client + * Decodes and returns integration id from integration token. + * + * @param token - encoded integration token */ - public test(): void { - const fakeEvent = new Error('Hawk JavaScript Catcher test message.'); + private static decodeIntegrationId(token: EncodedIntegrationToken): string { + try { + const decodedIntegrationToken: DecodedIntegrationToken = JSON.parse(atob(token)); + const { integrationId } = decodedIntegrationToken; - this.send(fakeEvent); - } + if (!integrationId || integrationId === '') { + throw new Error(); + } - /** - * Public method for manual sending messages to the Hawk - * Can be called in user's try-catch blocks or by other custom logic - * - * @param message - what to send - * @param [context] - any additional data to send - */ - public send(message: Error | string, context?: EventContext): void { - void this.formatAndSend(message, undefined, context); + return integrationId; + } catch { + throw new Error('Invalid integration token.'); + } } /** @@ -255,7 +197,7 @@ export default class Catcher { * Allows to send errors to Hawk with additional Frameworks data (addons) * * @param error - error to send - * @param [addons] - framework-specific data, can be undefined + * @param addons - framework-specific data, can be undefined */ // eslint-disable-next-line @typescript-eslint/no-explicit-any public captureError(error: Error | string, addons?: JavaScriptCatcherIntegrations): void { @@ -283,56 +225,17 @@ export default class Catcher { } /** - * Update the current user information - * - * @param user - New user information - */ - public setUser(user: AffectedUser): void { - if (!validateUser(user)) { - return; - } - - this.userManager.setUser(user); - } - - /** - * Clear current user information + * Returns {@link Catcher.type} */ - public clearUser(): void { - this.userManager.clear(); + protected getCatcherType(): typeof Catcher.type { + return Catcher.type; } /** - * Breadcrumbs API - provides convenient access to breadcrumb methods - * - * @example - * hawk.breadcrumbs.add({ - * type: 'user', - * category: 'auth', - * message: 'User logged in', - * level: 'info', - * data: { userId: '123' } - * }); + * Returns catcher version */ - public get breadcrumbs(): BreadcrumbStore { - return { - add: (breadcrumb, hint) => this.breadcrumbStore?.add(breadcrumb, hint), - get: () => this.breadcrumbStore?.get() ?? [], - clear: () => this.breadcrumbStore?.clear(), - }; - } - - /** - * Update the context data that will be sent with all events - * - * @param context - New context data - */ - public setContext(context: EventContext | undefined): void { - if (!validateContext(context)) { - return; - } - - this.context = context; + protected getCatcherVersion(): string { + return VERSION; } /** @@ -349,289 +252,23 @@ export default class Catcher { * @param {ErrorEvent|PromiseRejectionEvent} event — (!) both for Error and Promise Rejection */ private async handleEvent(event: ErrorEvent | PromiseRejectionEvent): Promise { - /** - * Add error to console logs - */ - + // Add error to console logs if (this.consoleTracking) { this.consoleCatcher!.addErrorEvent(event); } - /** - * Promise rejection reason is recommended to be an Error, but it can be a string: - * - Promise.reject(new Error('Reason message')) ——— recommended - * - Promise.reject('Reason message') - */ + // Promise rejection reason is recommended to be an Error, but it can be a string: + // - Promise.reject(new Error('Reason message')) ——— recommended + // - Promise.reject('Reason message') let error = (event as ErrorEvent).error || (event as PromiseRejectionEvent).reason; - /** - * Case when error triggered in external script - * We can't access event error object because of CORS - * Event message will be 'Script error.' - */ + // Case when error triggered in external script + // We can't access event error object because of CORS + // Event message will be 'Script error.' if (event instanceof ErrorEvent && error === undefined) { error = (event as ErrorEvent).message; } void this.formatAndSend(error); } - - /** - * Process and sends error message. - * - * Returns early without sending either if - * - error was already processed, - * - message processor drops it - * - {@link beforeSend} hook rejects it - * - * @param error - error to send - * @param integrationAddons - addons spoiled by Integration - * @param context - any additional data passed by user - */ - private async formatAndSend( - error: Error | string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - integrationAddons?: JavaScriptCatcherIntegrations, - context?: EventContext - ): Promise { - try { - const isAlreadySentError = isErrorProcessed(error); - - if (isAlreadySentError) { - /** - * @todo add debug build and log this case - */ - return; - } else { - markErrorAsProcessed(error); - } - - let processingPayload = await this.buildBasePayload(error, context); - - for (const processor of this.messageProcessors) { - const result = processor.apply(processingPayload, error); - - if (result === null) { - return; - } - - processingPayload = result; - } - - const payload = processingPayload as CatcherMessagePayload; - - if (integrationAddons) { - payload.addons = { - ...(payload.addons ?? {}), - ...Sanitizer.sanitize(integrationAddons), - }; - } - - const payloadPostBeforeSend = this.applyBeforeSendHook(payload); - - if (payloadPostBeforeSend === null) { - // Event was rejected by user using the beforeSend method - return; - } - - this.sendMessage({ - token: this.token, - catcherType: Catcher.type, - payload: payloadPostBeforeSend, - } as CatcherMessage); - } catch (e) { - log('Unable to send error. Seems like it is Hawk internal bug. Please, report it here: https://github.com/codex-team/hawk.javascript/issues/new', 'warn', e); - } - } - - /** - * Builds base event payload with basic fields (title, type, backtrace, user, context, release). - * - * @param error - caught error or string reason - * @param context - per-call context to merge with instance-level context - * @returns base payload with core data - */ - private async buildBasePayload( - error: Error | string, - context?: EventContext - ): Promise> { - return { - title: this.getTitle(error), - type: this.getType(error), - release: this.getRelease(), - context: this.getContext(context), - user: this.getUser(), - backtrace: await this.getBacktrace(error), - catcherVersion: this.version, - addons: {}, - }; - } - - /** - * Clones {@link payload} and applies user-supplied {@link beforeSend} hook against it. - * - * @param payload - processed event message payload - * @returns possibly modified payload, or null if the event should be dropped - */ - private applyBeforeSendHook( - payload: CatcherMessagePayload - ): CatcherMessagePayload | null { - if (typeof this.beforeSend !== 'function') { - return payload; - } - - let clone: CatcherMessagePayload; - - try { - clone = structuredClone(payload); - } catch { - // structuredClone may fail on non-cloneable values (functions, DOM nodes, etc.) - // Fall back to passing the original — hook may mutate it, but at least reporting won't crash - clone = payload; - } - - const result = this.beforeSend(clone); - - // false → drop event - if (result === false) { - return null; - } - - // Valid event payload → use it instead of original - if (isValidEventPayload(result)) { - return result as CatcherMessagePayload; - } - - // Anything else is invalid — warn, payload stays untouched (hook only received a clone) - log( - 'Invalid beforeSend value. It should return event or false. Event is sent without changes.', - 'warn' - ); - - return payload; - } - - /** - * Dispatches assembled message over configured transport. - * - * @param message - fully assembled catcher message ready to send - */ - private sendMessage(message: CatcherMessage): void { - this.transport.send(message) - .catch((e) => log('Transport sending error', 'error', e)); - } - - /** - * Return event title - * - * @param error - event from which to get the title - */ - private getTitle(error: Error | string): string { - const notAnError = !(error instanceof Error); - - /** - * Case when error is 'reason' of PromiseRejectionEvent - * and reject() provided with text reason instead of Error() - */ - if (notAnError) { - return error.toString() as string; - } - - return (error as Error).message; - } - - /** - * Return event type: TypeError, ReferenceError etc - * - * @param error - caught error - */ - private getType(error: Error | string): HawkJavaScriptEvent['type'] { - const notAnError = !(error instanceof Error); - - /** - * Case when error is 'reason' of PromiseRejectionEvent - * and reject() provided with text reason instead of Error() - */ - if (notAnError) { - return undefined; - } - - return (error as Error).name; - } - - /** - * Release version - */ - private getRelease(): HawkJavaScriptEvent['release'] { - return this.release !== undefined ? String(this.release) : undefined; - } - - /** - * Returns integration id from integration token - */ - private getIntegrationId(): string { - try { - const decodedIntegrationToken: DecodedIntegrationToken = JSON.parse(atob(this.token)); - const { integrationId } = decodedIntegrationToken; - - if (!integrationId || integrationId === '') { - throw new Error(); - } - - return integrationId; - } catch { - throw new Error('Invalid integration token.'); - } - } - - /** - * Collects additional information - * - * @param context - any additional data passed by user - */ - private getContext(context?: EventContext): HawkJavaScriptEvent['context'] { - const contextMerged = {}; - - if (this.context !== undefined) { - Object.assign(contextMerged, this.context); - } - - if (context !== undefined) { - Object.assign(contextMerged, context); - } - - return Sanitizer.sanitize(contextMerged); - } - - /** - * Returns the current user if set, otherwise generates and persists an anonymous ID. - */ - private getUser(): AffectedUser { - return this.userManager.getUser(); - } - - /** - * Return parsed backtrace information - * - * @param error - event from which to get backtrace - */ - private async getBacktrace(error: Error | string): Promise { - const notAnError = !(error instanceof Error); - - /** - * Case when error is 'reason' of PromiseRejectionEvent - * and reject() provided with text reason instead of Error() - */ - if (notAnError) { - return undefined; - } - - try { - return await this.stackParser.parse(error as Error); - } catch (e) { - log('Can not parse stack:', 'warn', e); - - return undefined; - } - } } From 5711628d10e8294034079cc85d3caad017df1b42 Mon Sep 17 00:00:00 2001 From: Reversean Date: Wed, 22 Apr 2026 20:51:17 +0300 Subject: [PATCH 4/4] docs(core): readme added --- packages/core/README.md | 106 ++++++++++++++++++++++++++++++++++ packages/javascript/README.md | 2 +- 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 packages/core/README.md diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 00000000..dc5312e7 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,106 @@ +# Hawk JavaScript Core + +Environment-agnostic base for Hawk JavaScript/TypeScript SDKs. + +## Installation + +```shell +npm install @hawk.so/core --save +``` + +```shell +yarn add @hawk.so/core +``` + +## What is inside + +| Export | Purpose | +|----------------------------------------------------------------------------------|-------------------------------------------------------------------------------------| +| `BaseCatcher` | Abstract catcher. Handles send pipeline, context, user, breadcrumbs. | +| `BeforeSendHook` | User hook applied to every outgoing event. Applied after `MessageProcessor`'s. | +| `BreadcrumbStore` | Breadcrumbs storage contract. Also serves as public breadcrumbs API. | +| `HawkUserManager` | Resolves affected user. Persists auto-generated anonymous ID via `HawkStorage`. | +| `HawkStorage` | Key–value persistence interface (e.g. `localStorage`, file, memory). | +| `log` / `setLogger` | Binding point for environment-specific logger implementation. | +| `MessageProcessor` | Pipeline step applied to every outgoing event. May enrich, replace or drop payload. | +| `RandomGenerator` | Source of random bytes for ID generation. | +| `Transport` | Interface for message delivery to Collector. | +| `Sanitizer` | Trims long strings, flattens big/deep objects, formats class instances. | +| `StackParser` | Parses `Error.stack` into structured backtrace frames with source code context. | +| `validateUser` / `validateContext` / `isValidEventPayload` / `isValidBreadcrumb` | Runtime validators used across SDKs. | + +## Building your own catcher + +Extend `BaseCatcher` and supply environment-specific pieces via constructor. + +```ts +import { + BaseCatcher, + HawkUserManager, + setLogger, + type Transport, + type HawkStorage, + type RandomGenerator, +} from '@hawk.so/core'; + +// 1. Provide a transport that delivers assembled messages to Collector +class MyTransport implements Transport<'errors/javascript'> { + public async send(message): Promise { + // e.g. WebSocket, fetch, IPC — whatever fits the runtime + } +} + +// 2. Provide persistence for the anonymous user ID +class MyStorage implements HawkStorage { + public getItem(key) { + // ... + } + + public setItem(key, value) { + // ... + } + + public removeItem(key) { + // ... + } +} + +// 3. Provide randomness (crypto.getRandomValues, node:crypto, …) +class MyRandom implements RandomGenerator { + public getRandomNumbers(length) { /* … */ + } +} + +// 4. Optionally, register logger +setLogger((msg, type, args) => console[type ?? 'log'](msg, args)); + +// 5. Extend BaseCatcher +export class MyCatcher extends BaseCatcher<'errors/javascript'> { + public constructor(token: string) { + const userManager = new HawkUserManager(new MyStorage(), new MyRandom()); + + super( + token, + new MyTransport(), + userManager, + /* release */ undefined, + /* context */ undefined, + /* beforeSend */ undefined, + /* breadcrumbStore */ undefined + ); + } + + protected getCatcherType() { + return 'errors/javascript' as const; + } + + protected getCatcherVersion() { + return '1.0.0'; + } +} +``` + +## License + +This project is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**. +See the [LICENSE](../../LICENSE) file for the full text. diff --git a/packages/javascript/README.md b/packages/javascript/README.md index 8cd86fc4..7deb8bae 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -314,4 +314,4 @@ const hawk = new HawkCatcher({ ## License This project is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**. -See the [LICENSE](./LICENSE) file for the full text. +See the [LICENSE](../../LICENSE) file for the full text.