From ca2f2c3cad6763b76abf28ba821693a9bfceac73 Mon Sep 17 00:00:00 2001 From: Reversean Date: Wed, 8 Apr 2026 12:12:57 +0300 Subject: [PATCH 1/2] 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 | 470 +++++++++++++++++++++++++ packages/core/src/index.ts | 2 + packages/javascript/src/catcher.ts | 532 +++++------------------------ 3 files changed, 552 insertions(+), 452 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 0000000..2200eda --- /dev/null +++ b/packages/core/src/catcher.ts @@ -0,0 +1,470 @@ +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 { BreadcrumbsMessageProcessor } from './messages/breadcrumbs-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. + * When provided, {@link BreadcrumbsMessageProcessor} is registered automatically + * and breadcrumbs snapshot is captured into every outgoing event. + * + * **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; registers {@link BreadcrumbsMessageProcessor} when provided + */ + 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); + + if (breadcrumbStore) { + this.messageProcessors.push(new BreadcrumbsMessageProcessor()); + } + } + + /** + * 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); + + const hint = { + error, + breadcrumbs: this.breadcrumbStore?.get(), + }; + + let processingPayload = await this.buildBasePayload(error, context); + + for (const processor of this.messageProcessors) { + const result = processor.apply(processingPayload, hint); + + 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 7357da6..65f25d2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,3 +14,5 @@ export { isErrorProcessed, markErrorAsProcessed } from './utils/event'; export type { BreadcrumbStore, BreadcrumbsAPI, BreadcrumbHint, BreadcrumbInput } from './breadcrumbs/breadcrumb-store'; export type { ErrorSnapshot, MessageProcessor, ProcessingPayload } from './messages/message-processor'; export { BreadcrumbsMessageProcessor } from './messages/breadcrumbs-message-processor'; +export { BaseCatcher } from './catcher'; +export type { BeforeSendHook } from './catcher'; diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 462906f..ebafc30 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -1,33 +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 { - BreadcrumbsMessageProcessor, - 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 +32,7 @@ if (!isLoggerSet()) { * * @copyright CodeX */ -export default class Catcher { +export default class Catcher extends BaseCatcher { /** * JS Catcher version */ @@ -69,45 +48,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 +68,6 @@ export default class Catcher { */ private readonly consoleCatcher: ConsoleCatcher | null = null; - /** - * Breadcrumb store instance - */ - private readonly breadcrumbStore: BrowserBreadcrumbStore | 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 +80,51 @@ 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' + ); + }, + }); + + // Initialize breadcrumbs + let breadcrumbStore: BrowserBreadcrumbStore | null = null; + + if (token && settings.breadcrumbs !== false) { + breadcrumbStore = BrowserBreadcrumbStore.getInstance(); + breadcrumbStore.init(settings.breadcrumbs ?? {}); + } + + 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 +133,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,48 +144,23 @@ 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 - */ - if (settings.breadcrumbs !== false) { - this.breadcrumbStore = BrowserBreadcrumbStore.getInstance(); - this.breadcrumbStore.init(settings.breadcrumbs ?? {}); - this.messageProcessors.push(new BreadcrumbsMessageProcessor()); - } else { - this.breadcrumbStore = null; - } 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(); } @@ -234,23 +171,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.'); + } } /** @@ -258,7 +195,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 { @@ -286,56 +223,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; } /** @@ -352,293 +250,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); - } - - const snapshot = { - error, - breadcrumbs: this.breadcrumbStore?.get() - }; - let processingPayload = await this.buildBasePayload(error, context); - - for (const processor of this.messageProcessors) { - const result = processor.apply(processingPayload, snapshot); - - 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 22fa22f986498885c5cd845534ca19ac9c1eb1dd Mon Sep 17 00:00:00 2001 From: Reversean Date: Wed, 22 Apr 2026 20:51:17 +0300 Subject: [PATCH 2/2] docs(core): readme added --- packages/core/README.md | 152 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 packages/core/README.md diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..de03e72 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,152 @@ +## Hawk Core + +Environment-agnostic base for Hawk JavaScript SDKs. + +`@hawk.so/core` contains shared logic that every Hawk catcher reuses — the send pipeline, user identity, breadcrumbs contract, data sanitization, stack parsing, logging — without assuming anything about the runtime. Environment-specific SDKs (browser, SvelteKit, Node, …) plug in their own transport, storage and random generator to build a complete catcher on top of it. + +If you are looking to track errors in an application, use an environment-specific package such as [@hawk.so/javascript](../javascript). This package is intended for SDK authors. + +## 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, `beforeSend`. | +| `HawkUserManager` | Resolves affected user. Persists auto-generated anonymous ID via `HawkStorage`. | +| `Transport` | Interface for message delivery to the Collector. | +| `HawkStorage` | Key–value persistence interface (e.g. `localStorage`, file, memory). | +| `RandomGenerator` | Source of random bytes for ID generation. | +| `MessageProcessor` | Pipeline step applied to every outgoing event. May enrich, replace or drop payload. | +| `BreadcrumbStore` | Breadcrumbs storage contract. Also serves as public `hawk.breadcrumbs` API. | +| `BreadcrumbsMessageProcessor` | Built-in processor attaching breadcrumbs snapshot to every event. | +| `Sanitizer` | Trims long strings, flattens big/deep objects, formats class instances. | +| `StackParser` | Parses `Error.stack` into structured backtrace frames with source code context. | +| `setLogger` / `log` | Binding point for environment-specific logger implementation. | +| `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 a logger so that core can surface warnings +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'; + } +} +``` + +`MyCatcher` now exposes the full public surface inherited from `BaseCatcher`: `send`, `test`, `setUser`, `clearUser`, `setContext`, `breadcrumbs`. + +## Send pipeline + +Every event goes through the same stages inside `BaseCatcher`: + +1. Build base payload — title, type, release, user, context, backtrace, catcher version. +2. Run registered `MessageProcessor`s in order. Processors may enrich the payload (e.g. browser addons, console output) or return `null` to drop it. +3. Apply the optional `beforeSend` hook on a `structuredClone` of the payload. Return the modified event, or `false` to drop it. +4. Dispatch via the provided `Transport`. + +If a `BreadcrumbStore` is provided to the constructor, `BreadcrumbsMessageProcessor` is registered automatically and a breadcrumbs snapshot is captured into every outgoing event. + +## Adding processors + +Subclasses register environment-specific processors via the protected `addMessageProcessor` method: + +```ts +this.addMessageProcessor(new BrowserAddonMessageProcessor()); +this.addMessageProcessor(new ConsoleOutputAddonMessageProcessor(consoleCatcher)); +``` + +A processor implements `MessageProcessor`: + +```ts +import type { MessageProcessor, ProcessingPayload, ErrorSnapshot } from '@hawk.so/core'; + +class ReleaseTagProcessor implements MessageProcessor<'errors/javascript'> { + public apply(payload: ProcessingPayload<'errors/javascript'>, snapshot?: ErrorSnapshot) { + payload.addons = { ...payload.addons, buildTag: process.env.BUILD_TAG }; + + return payload; + } +} +``` + +Returning `null` from `apply` drops the event before it reaches transport. + +## Sanitizer + +`Sanitizer.sanitize(data)` is applied to context and integration addons before sending. It trims long strings, caps array/object size and depth, detects circular references and formats classes as `` / ``. Custom type handlers can be registered: + +```ts +import { Sanitizer } from '@hawk.so/core'; + +Sanitizer.registerHandler({ + check: (value) => value instanceof MyDomainObject, + format: (value) => ({ kind: 'MyDomainObject', id: value.id }), +}); +``` + +## 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.