diff --git a/packages/javascript/src/addons/consoleCatcher.ts b/packages/javascript/src/addons/consoleCatcher.ts index f5742b7..2843ca7 100644 --- a/packages/javascript/src/addons/consoleCatcher.ts +++ b/packages/javascript/src/addons/consoleCatcher.ts @@ -2,6 +2,7 @@ * @file Module for intercepting console logs with stack trace capture */ import type { ConsoleLogEvent } from '@hawk.so/types'; +import { getErrorFromErrorEvent } from '../utils/error'; import { Sanitizer } from '@hawk.so/core'; /** @@ -195,13 +196,15 @@ export class ConsoleCatcher { * @param event - The error event or promise rejection event to convert */ private createConsoleEventFromError(event: ErrorEvent | PromiseRejectionEvent): ConsoleLogEvent { + const capturedError = getErrorFromErrorEvent(event); + if (event instanceof ErrorEvent) { return { method: 'error', timestamp: new Date(), - type: event.error?.name || 'Error', - message: event.error?.message || event.message, - stack: event.error?.stack || '', + type: capturedError.type || 'Error', + message: capturedError.title, + stack: (capturedError.rawError as Error)?.stack || '', fileLine: event.filename ? `${event.filename}:${event.lineno}:${event.colno}` : '', @@ -212,8 +215,8 @@ export class ConsoleCatcher { method: 'error', timestamp: new Date(), type: 'UnhandledRejection', - message: event.reason?.message || String(event.reason), - stack: event.reason?.stack || '', + message: capturedError.title, + stack: (capturedError.rawError as Error)?.stack || '', fileLine: '', }; } diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 08b0e88..b02488b 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -9,7 +9,7 @@ import type { EventContext, JavaScriptAddons, Json, - VueIntegrationAddons + VueIntegrationAddons, } from '@hawk.so/types'; import type { JavaScriptCatcherIntegrations } from '@/types'; import { ConsoleCatcher } from './addons/consoleCatcher'; @@ -31,6 +31,7 @@ import { import { HawkLocalStorage } from './storages/hawk-local-storage'; import { createBrowserLogger } from './logger/logger'; import { BrowserRandomGenerator } from './utils/random'; +import { type CapturedError, composeCapturedError, getErrorFromErrorEvent } from './utils/error'; /** * Allow to use global VERSION, that will be overwritten by Webpack @@ -230,7 +231,7 @@ export default class Catcher { * @param [context] - any additional data to send */ public send(message: Error | string, context?: EventContext): void { - void this.formatAndSend(message, undefined, context); + void this.formatAndSend(composeCapturedError(message), undefined, context); } /** @@ -242,7 +243,7 @@ export default class Catcher { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any public captureError(error: Error | string, addons?: JavaScriptCatcherIntegrations): void { - void this.formatAndSend(error, addons); + void this.formatAndSend(composeCapturedError(error), addons); } /** @@ -255,7 +256,7 @@ export default class Catcher { this.vue = new VueIntegration( vue, (error: Error, addons: VueIntegrationAddons) => { - void this.formatAndSend(error, { + void this.formatAndSend(composeCapturedError(error), { vue: addons, }); }, @@ -340,21 +341,7 @@ export default class Catcher { 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') - */ - 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.' - */ - if (event instanceof ErrorEvent && error === undefined) { - error = (event as ErrorEvent).message; - } + const error = getErrorFromErrorEvent(event); void this.formatAndSend(error); } @@ -367,13 +354,13 @@ export default class Catcher { * @param context - any additional data passed by user */ private async formatAndSend( - error: Error | string, + error: CapturedError, // eslint-disable-next-line @typescript-eslint/no-explicit-any integrationAddons?: JavaScriptCatcherIntegrations, context?: EventContext ): Promise { try { - const isAlreadySentError = isErrorProcessed(error); + const isAlreadySentError = isErrorProcessed(error.rawError); if (isAlreadySentError) { /** @@ -381,7 +368,7 @@ export default class Catcher { */ return; } else { - markErrorAsProcessed(error); + markErrorAsProcessed(error.rawError); } const errorFormatted = await this.prepareErrorFormatted(error, context); @@ -424,16 +411,17 @@ export default class Catcher { * @param error - error to format * @param context - any additional data passed by user */ - private async prepareErrorFormatted(error: Error | string, context?: EventContext): Promise> { + private async prepareErrorFormatted(error: CapturedError, context?: EventContext): Promise> { + const { title, type, rawError } = error; let payload: HawkJavaScriptEvent = { - title: this.getTitle(error), - type: this.getType(error), + title, + type, release: this.getRelease(), breadcrumbs: this.getBreadcrumbsForEvent(), context: this.getContext(context), user: this.getUser(), - addons: this.getAddons(error), - backtrace: await this.getBacktrace(error), + addons: this.getAddons(rawError), + backtrace: await this.getBacktrace(rawError), catcherVersion: this.version, }; @@ -485,44 +473,6 @@ export default class Catcher { }; } - /** - * 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 */ @@ -612,7 +562,7 @@ export default class Catcher { * * @param error - event from which to get backtrace */ - private async getBacktrace(error: Error | string): Promise { + private async getBacktrace(error: unknown): Promise { const notAnError = !(error instanceof Error); /** @@ -635,9 +585,9 @@ export default class Catcher { /** * Return some details * - * @param {Error|string} error — caught error + * @param {Error} error — caught error */ - private getAddons(error: Error | string): HawkJavaScriptEvent['addons'] { + private getAddons(error: unknown): HawkJavaScriptEvent['addons'] { const { innerWidth, innerHeight } = window; const userAgent = window.navigator.userAgent; const location = window.location.href; @@ -671,9 +621,9 @@ export default class Catcher { /** * Compose raw data object * - * @param {Error|string} error — caught error + * @param {Error} error — caught error */ - private getRawData(error: Error | string): Json | undefined { + private getRawData(error: unknown): Json | undefined { if (!(error instanceof Error)) { return; } diff --git a/packages/javascript/src/utils/error.ts b/packages/javascript/src/utils/error.ts new file mode 100644 index 0000000..21fe803 --- /dev/null +++ b/packages/javascript/src/utils/error.ts @@ -0,0 +1,109 @@ +import type { HawkJavaScriptEvent } from '@/types'; +import { Sanitizer } from '@hawk.so/core'; + +/** + * Represents a captured error in a normalized form. + * + * Motivation: + * - `Error | string` is unclear and hard to work with. + * - Fields can be filled from an event or from the error itself. + */ +export type CapturedError = { + /** Human-readable error message used as a title in the dashboard */ + title: string; + /** Error type (e.g. 'TypeError', 'NetworkError'), or undefined if unknown */ + type: HawkJavaScriptEvent['type']; + /** The original (unsanitized) value — use for instanceof checks and backtrace parsing only */ + rawError: unknown; +}; + +/** + * Extracts a human-readable title from an unknown sanitized error. + * Prefers `.message` on objects, falls back to the value itself for strings, + * and serializes everything else. + * + * @param sanitizedError - Value returned by `Sanitizer.sanitize(error)` + * @returns A non-empty string title, or undefined if the value is nullish or empty + */ +function getTitleFromError(sanitizedError: unknown): string | undefined { + if (sanitizedError == null) { + return undefined; + } + + const message = + typeof sanitizedError === 'object' && 'message' in sanitizedError ? (sanitizedError as Error).message : sanitizedError; + + if (typeof message === 'string') { + return message || undefined; + } + + try { + return JSON.stringify(message); + } catch { + // If no JSON global is available, fall back to string conversion + return String(message); + } +} + +/** + * Extracts an error type name from an unknown sanitized error. + * Returns `.name` only when it is a non-empty string (e.g. 'TypeError'). + * + * @param sanitizedError - Value returned by `Sanitizer.sanitize(error)` + * @returns The error name string, or undefined if absent or empty + */ +function getTypeFromError(sanitizedError: unknown): string | undefined { + return (sanitizedError as Error)?.name; +} + +/** + * Constructs a CapturedError from an unknown error value and optional fallbacks. + * The thrown value is first passed through `Sanitizer.sanitize(error)`. + * + * @param error - Any value thrown or rejected + * @param fallbackValues - Fallback values from event if they can't be extracted from the error + * @returns A normalized `CapturedError` object + */ +export function composeCapturedError( + error: unknown, + fallbackValues: { title?: string; type?: string } = {} +): CapturedError { + const sanitizedError = Sanitizer.sanitize(error); + + return { + title: getTitleFromError(sanitizedError) || fallbackValues.title || '', + type: getTypeFromError(sanitizedError) || fallbackValues.type, + rawError: error, + }; +} + +/** + * Extracts and normalizes error from ErrorEvent or PromiseRejectionEvent. + * Handles CORS-restricted errors (where event.error is undefined) by falling back to event.message. + * + * @param event - The error or promise rejection event + * @returns A normalized CapturedError object + */ +export function getErrorFromErrorEvent(event: ErrorEvent | PromiseRejectionEvent): CapturedError { + if (event.type === 'error') { + event = event as ErrorEvent; + + return composeCapturedError(event.error, { + title: event.message && (event.filename ? `'${event.message}' at ${event.filename}:${event.lineno}:${event.colno}` : event.message), + }); + } + + if (event.type === 'unhandledrejection') { + event = event as PromiseRejectionEvent; + + return composeCapturedError(event.reason, { + type: 'UnhandledRejection', + }); + } + + /* + Fallback case: ensures function always returns CapturedError. + composeCapturedError(undefined) yields object with undefined fields. + */ + return composeCapturedError(undefined); +} diff --git a/packages/javascript/tests/catcher.global-handlers.test.ts b/packages/javascript/tests/catcher.global-handlers.test.ts index 34c1810..9f8b124 100644 --- a/packages/javascript/tests/catcher.global-handlers.test.ts +++ b/packages/javascript/tests/catcher.global-handlers.test.ts @@ -80,7 +80,7 @@ describe('Catcher', () => { await wait(); expect(sendSpy).toHaveBeenCalledOnce(); - expect(getLastPayload(sendSpy).title).toBe('Script error.'); + expect(getLastPayload(sendSpy).title).toBe("Script error."); }); it('should capture unhandled promise rejections', async () => { diff --git a/packages/javascript/tests/utils/error.test.ts b/packages/javascript/tests/utils/error.test.ts new file mode 100644 index 0000000..9de5728 --- /dev/null +++ b/packages/javascript/tests/utils/error.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getErrorFromErrorEvent } from '../../src/utils/error'; + +vi.mock('@hawk.so/core', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + log: vi.fn(), + isLoggerSet: vi.fn(() => true), + setLogger: vi.fn(), + }; +}); + +describe('getErrorFromErrorEvent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('ErrorEvent', () => { + it('should capture Error instance with correct fields', () => { + const error = new Error('Test error'); + const event = new ErrorEvent('error', { error }); + const result = getErrorFromErrorEvent(event); + + expect(result.rawError).toBe(error); + expect(result.title).toBe('Test error'); + expect(result.type).toBe('Error'); + }); + + it('should capture DOMException with correct fields', () => { + const error = new DOMException('Network error', 'NetworkError'); + const event = new ErrorEvent('error', { error }); + const result = getErrorFromErrorEvent(event); + + expect(result.rawError).toBe(error); + expect(result.title).toBe(''); + }); + + it('should fall back to event.message when event.error is not provided', () => { + const event = new ErrorEvent('error', { + message: 'Script error.', + filename: 'app.js', + lineno: 10, + colno: 5, + }); + const result = getErrorFromErrorEvent(event); + + expect(result.rawError).toBeNull(); + expect(result.title).toContain('Script error.'); + expect(result.title).toContain('app.js:10:5'); + }); + + it('should return unknown error title when event.error and message are both absent', () => { + const event = new ErrorEvent('error', { message: '' }); + const result = getErrorFromErrorEvent(event); + + expect(result.title).toBe(''); + }); + }); + + describe('PromiseRejectionEvent', () => { + it('should capture Error reason with correct fields', () => { + const reason = new Error('Promise rejected'); + const event = new PromiseRejectionEvent('unhandledrejection', { + promise: Promise.resolve(), + reason, + }); + const result = getErrorFromErrorEvent(event); + + expect(result.rawError).toBe(reason); + expect(result.title).toBe('Promise rejected'); + expect(result.type).toBe('Error'); + }); + + it('should capture string reason', () => { + const reason = 'Something went wrong'; + const event = new PromiseRejectionEvent('unhandledrejection', { + promise: Promise.resolve(), + reason, + }); + const result = getErrorFromErrorEvent(event); + + expect(result.rawError).toBe(reason); + expect(result.title).toBe('Something went wrong'); + expect(result.type).toBe('UnhandledRejection'); + }); + + it('should capture plain object reason', () => { + const reason = { code: 'ERR_001', details: 'Something went wrong' }; + const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); + const result = getErrorFromErrorEvent(event); + + expect(result.rawError).toBe(reason); + expect(result.title).toBe('{"code":"ERR_001","details":"Something went wrong"}'); + expect(result.type).toBe('UnhandledRejection'); + }); + + it('should handle undefined reason', () => { + const event = new PromiseRejectionEvent('unhandledrejection', { + promise: Promise.resolve(), + reason: undefined, + }); + const result = getErrorFromErrorEvent(event); + + expect(result.rawError).toBeUndefined(); + expect(result.title).toBe(''); + }); + + it('should handle null reason', () => { + const event = new PromiseRejectionEvent('unhandledrejection', { + promise: Promise.resolve(), + reason: null, + }); + const result = getErrorFromErrorEvent(event); + + expect(result.rawError).toBeNull(); + expect(result.title).toBe(''); + }); + + it('should handle circular references in object reason', () => { + const circularObj: Record = { name: 'test' }; + circularObj.self = circularObj; + const event = new PromiseRejectionEvent('unhandledrejection', { + promise: Promise.resolve(), + reason: circularObj, + }); + const result = getErrorFromErrorEvent(event); + + expect(result.rawError).toBe(circularObj); + expect(result.title).toContain(''); + }); + }); + + describe('deduplication identity', () => { + it('rawError should preserve reference to the original object for deduplication', () => { + const error = new Error('Test'); + const event = new ErrorEvent('error', { error }); + const result1 = getErrorFromErrorEvent(event); + const result2 = getErrorFromErrorEvent(event); + + expect(result1.rawError).toBe(result2.rawError); + expect(result1.rawError).toBe(error); + }); + }); +});