import type { HawkEvent } from '../types/index.js'; import type { HawkNodeJSInitialSettings } from '../types/index.js'; import { Buffer } from 'buffer'; import process from 'process'; import type { EventContext, AffectedUser, Breadcrumb, EncodedIntegrationToken, DecodedIntegrationToken, EventData, NodeJSAddons, Json } from '@hawk.so/types'; import EventPayload from './modules/event.js'; import { BreadcrumbManager, type BreadcrumbInput, type BreadcrumbHint } from './modules/breadcrumbs.js'; import { isValidEventPayload } from './utils/validate-event.js'; import type { AxiosResponse } from 'axios'; import axios from 'axios'; import { VERSION } from './version.js'; /** * Class for throwing errors inside unhandledRejection processor */ class UnhandledRejection extends Error {} /** * Instance of HawkCatcher for singleton */ let _instance: Catcher; /** * Hawk NodeJS Catcher * Module for errors and exceptions tracking * @copyright CodeX */ class Catcher { /** * Whether breadcrumbs are enabled (false when settings.breadcrumbs === false) */ public readonly breadcrumbsEnabled: boolean; /** * Type is a family name of a catcher */ private readonly type: string = 'errors/nodejs'; /** * User project's Integration Token */ private readonly token: EncodedIntegrationToken; /** * Collector's url */ private readonly collectorEndpoint: string; /** * Release identifier */ private readonly release?: string; /** * Any other information to send with event */ private readonly context?: EventContext; /** * 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?: (event: EventData) => EventData | false | void; /** * @param settings - If settings is a string, it means an Integration Token */ constructor(settings: HawkNodeJSInitialSettings | string) { if (typeof settings === 'string') { settings = { token: settings, } as HawkNodeJSInitialSettings; } this.token = settings.token; this.context = settings.context ?? undefined; this.release = settings.release ?? undefined; this.beforeSend = settings.beforeSend?.bind(undefined); this.breadcrumbsEnabled = settings.breadcrumbs !== false; if (this.breadcrumbsEnabled) { BreadcrumbManager.getInstance().init( typeof settings.breadcrumbs === 'object' && settings.breadcrumbs !== null ? settings.breadcrumbs : {} ); } if (!this.token) { throw new Error('Integration Token is missed. You can get it on https://hawk.so at Project Settings.'); } try { const integrationId = this.getIntegrationId(); this.collectorEndpoint = settings.collectorEndpoint ?? `https://${integrationId}.k1.hawk.so/`; /** * Set global handlers */ if (settings.disableGlobalErrorsHandling !== true) { this.initGlobalHandlers(); } } catch (_error) { throw new Error('Invalid integration token'); } } /** * Catcher package version */ private static getVersion(): string { if (VERSION !== undefined && VERSION !== null) { return String(VERSION); } return ''; } /** * Send test event from client */ public test(): void { /** * Create a dummy error event * Error: Hawk NodeJS Catcher test message */ const fakeEvent = new Error('Hawk NodeJS Catcher test message'); /** * Catch it and send to Hawk */ this.send(fakeEvent); } /** * This method prepares and sends an Error to Hawk * User can fire it manually on try-catch * @param error - error to catch * @param context — event context * @param user - User identifier */ public send(error: Error, context?: EventContext, user?: AffectedUser): void { /** * Compose and send a request to Hawk */ this.formatAndSend(error, context, user); } /** * Add a breadcrumb to the buffer (no-op when breadcrumbs are disabled) * @param breadcrumb - Breadcrumb data (type, message, category, level, data) * @param hint - Optional hint for beforeBreadcrumb callback */ public addBreadcrumb(breadcrumb: BreadcrumbInput, hint?: BreadcrumbHint): void { if (this.breadcrumbsEnabled) { BreadcrumbManager.getInstance().addBreadcrumb(breadcrumb, hint); } } /** * Get current breadcrumbs snapshot (oldest to newest) */ public getBreadcrumbs(): Breadcrumb[] { return this.breadcrumbsEnabled ? BreadcrumbManager.getInstance().getBreadcrumbs() : []; } /** * Clear all breadcrumbs */ public clearBreadcrumbs(): void { if (this.breadcrumbsEnabled) { BreadcrumbManager.getInstance().clear(); } } /** * Returns integration id from integration token */ private getIntegrationId(): string { const decodedIntegrationTokenAsString = Buffer .from(this.token, 'base64') .toString('utf-8'); const decodedIntegrationToken = JSON.parse(decodedIntegrationTokenAsString) as DecodedIntegrationToken; const integrationId = decodedIntegrationToken.integrationId; if (!integrationId || integrationId === '') { throw new Error('Invalid integration token. There is no integration ID.'); } return integrationId; } /** * Define own error handlers */ private initGlobalHandlers(): void { /** * Catch unhandled exceptions */ process.on('uncaughtException', (err: Error) => { /** * Show error data in console */ console.error(err); /** * Process error catching */ this.send(err); }); /** * Catch unhandled rejections */ process.on('unhandledRejection', (error: Error | string) => { /** * Correct reject processing */ if (error instanceof Error) { if (_instance !== undefined) { _instance.send(error); } } /** * If only error string was passed on reject */ if (typeof error === 'string') { /** * Event is a string with reject info * * Promise.reject('Wrong database key') * will throw: 'Wrong database key' */ const reason = `Unhandled rejection: ${error}`; if (_instance !== undefined) { _instance.send(new UnhandledRejection(reason)); } } /** * If we know nothing about an error */ if (error === undefined) { const reason = 'Unhandled rejection'; if (_instance !== undefined) { _instance.send(new UnhandledRejection(reason)); } } console.error('Error occurred without a catch block inside the asynchronous function, or because a promise was rejected that was not processed using .catch().\nPromise rejected due to:', error); }); }; /** * Format and send an error * @param err - error to send * @param context — event context * @param user - User identifier */ private formatAndSend(err: Error, context?: EventContext, user?: AffectedUser): void { const eventPayload = new EventPayload(err); const breadcrumbs = this.breadcrumbsEnabled ? BreadcrumbManager.getInstance().getBreadcrumbs() : []; let payload = { title: eventPayload.getTitle(), type: eventPayload.getType(), backtrace: eventPayload.getBacktrace(), user: this.getUser(user), context: this.getContext(context), release: this.release, catcherVersion: Catcher.getVersion(), breadcrumbs: breadcrumbs.length > 0 ? breadcrumbs : null, } as EventData; /** * Filter sensitive data */ if (typeof this.beforeSend === 'function') { let eventPayloadClone: EventData; try { eventPayloadClone = structuredClone(payload); } catch { /** * structuredClone may fail on non-cloneable values (functions, class instances, etc.) * Fall back to passing the original — hook may mutate it, but at least reporting won't crash */ eventPayloadClone = payload; } const result = this.beforeSend(eventPayloadClone); /** * false → drop event */ if (result === false) { return; } /** * Valid event payload → use it instead of original */ if (isValidEventPayload(result)) { payload = result; } else { /** * Anything else is invalid — warn, payload stays untouched (hook only received a clone) */ console.warn('[Hawk] Invalid beforeSend value. It should return event or false. Event is sent without changes.'); } } void this.sendErrorFormatted({ token: this.token, catcherType: this.type, payload, }); } /** * Sends formatted EventData to the Collector * @param eventFormatted - prepared and formatted event to send */ // eslint-disable-next-line @typescript-eslint/no-explicit-any private sendErrorFormatted(eventFormatted: HawkEvent): Promise> { return axios.post(this.collectorEndpoint, eventFormatted) .catch((err: Error) => { console.error(`[Hawk] Cannot send an event because of ${err.toString()}`); }); } /** * Compose User object * @param user - User identifier */ private getUser(user?: AffectedUser): AffectedUser | undefined { return user; } /** * Compose context object * @param context - Any other information to send with event */ private getContext(context?: EventContext): Json { const contextMerged = {}; if (this.context !== undefined) { Object.assign(contextMerged, this.context); } if (context !== undefined) { Object.assign(contextMerged, context); } return contextMerged; } } /** * Wrapper for Hawk NodeJS Catcher */ export default class HawkCatcher { /** * Wrapper for HawkCatcher constructor * @param settings - If settings is a string, it means an Integration Token */ public static init(settings: HawkNodeJSInitialSettings | string): void { _instance = new Catcher(settings); } /** * Wrapper for HawkCatcher.send() method * * This method prepares and sends an Error to Hawk * User can fire it manually on try-catch * @param error - error to catch * @param context — event context * @param user - User identifier */ public static send(error: Error, context?: EventContext, user?: AffectedUser): void { /** * If instance is undefined then do nothing */ if (_instance !== undefined) { return _instance.send(error, context, user); } } /** * Breadcrumbs API (same as in JS catcher: add, get, clear). * No-op when breadcrumbs were disabled (breadcrumbs: false) or Catcher is not initialized. */ public static get breadcrumbs(): BreadcrumbsAPI { return { add: (breadcrumb, hint) => { if (_instance !== undefined) { _instance.addBreadcrumb(breadcrumb, hint); } }, get: () => _instance !== undefined ? _instance.getBreadcrumbs() : [], clear: () => { if (_instance !== undefined) { _instance.clearBreadcrumbs(); } }, }; } } /** * Breadcrumbs API - same surface as in @hawk.so/javascript (add, get, clear) */ export interface BreadcrumbsAPI { /** Add a breadcrumb to the buffer (attached to every event until cleared) */ add(breadcrumb: BreadcrumbInput, hint?: BreadcrumbHint): void; /** Get current breadcrumbs snapshot (oldest to newest) */ get(): Breadcrumb[]; /** Clear all breadcrumbs */ clear(): void; } export type { BreadcrumbInput, BreadcrumbHint, BreadcrumbsOptions } from './modules/breadcrumbs.js'; export type { HawkNodeJSInitialSettings } from '../types/index.js';