diff --git a/packages/javascript/src/addons/consoleCatcher.ts b/packages/javascript/src/addons/consoleCatcher.ts index f5742b7..564f60c 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, getTitleFromError, getTypeFromError } from '../utils/error'; import { Sanitizer } from '@hawk.so/core'; /** @@ -195,13 +196,18 @@ export class ConsoleCatcher { * @param event - The error event or promise rejection event to convert */ private createConsoleEventFromError(event: ErrorEvent | PromiseRejectionEvent): ConsoleLogEvent { + const errorSource = getErrorFromErrorEvent(event); + const sanitizedError = Sanitizer.sanitize(errorSource.rawError); + const message = getTitleFromError(sanitizedError) ?? errorSource.fallbackTitle ?? ''; + const type = getTypeFromError(sanitizedError) ?? errorSource.fallbackType ?? 'Error'; + 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, + message, + stack: (errorSource.rawError as Error)?.stack || '', fileLine: event.filename ? `${event.filename}:${event.lineno}:${event.colno}` : '', @@ -212,8 +218,8 @@ export class ConsoleCatcher { method: 'error', timestamp: new Date(), type: 'UnhandledRejection', - message: event.reason?.message || String(event.reason), - stack: event.reason?.stack || '', + message, + stack: (errorSource.rawError as Error)?.stack || '', fileLine: '', }; } diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 08b0e88..2e3b582 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 ErrorSource, getErrorFromErrorEvent, getTitleFromError, getTypeFromError } 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({ rawError: 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({ rawError: 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({ rawError: 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: ErrorSource, // 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,22 @@ 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: ErrorSource, context?: EventContext): Promise> { + const { rawError, fallbackTitle, fallbackType } = error; + const sanitizedError = Sanitizer.sanitize(rawError); + const throwableError = rawError instanceof Error ? rawError : undefined; + const title = getTitleFromError(sanitizedError) ?? fallbackTitle ?? ''; + const type = getTypeFromError(sanitizedError) ?? fallbackType; + 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(throwableError), + backtrace: await this.getBacktrace(throwableError), catcherVersion: this.version, }; @@ -485,44 +478,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 */ @@ -610,21 +565,15 @@ export default class Catcher { /** * Return parsed backtrace information * - * @param error - event from which to get backtrace + * @param {Error} 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) { + private async getBacktrace(error?: Error): Promise { + if (!error) { return undefined; } try { - return await this.stackParser.parse(error as Error); + return await this.stackParser.parse(error); } catch (e) { log('Can not parse stack:', 'warn', e); @@ -635,9 +584,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?: Error): HawkJavaScriptEvent['addons'] { const { innerWidth, innerHeight } = window; const userAgent = window.navigator.userAgent; const location = window.location.href; @@ -671,10 +620,10 @@ 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 { - if (!(error instanceof Error)) { + private getRawData(error?: Error): Json | undefined { + if (!error) { return; } diff --git a/packages/javascript/src/utils/error.ts b/packages/javascript/src/utils/error.ts new file mode 100644 index 0000000..0d07993 --- /dev/null +++ b/packages/javascript/src/utils/error.ts @@ -0,0 +1,94 @@ +import type { HawkJavaScriptEvent } from '@/types'; + +/** + * Represents a raw error source before title/type normalization. + * Fallback values are provided by the event itself when raw error data is missing. + */ +export type ErrorSource = { + /** The original unsanitized value — use for instanceof checks and backtrace parsing only */ + rawError: unknown; + /** Fallback human-readable title used when rawError does not provide one */ + fallbackTitle?: string; + /** Fallback error type provided by the caller */ + fallbackType?: HawkJavaScriptEvent['type']; +}; + +/** + * Extracts a human-readable title from an unknown value. + * Prefers `.message` on objects, falls back to the value itself for strings, + * and serializes everything else. + * + * @param value - Any already-safe value prepared by the caller + * @returns The error title string, or undefined if absent or empty + */ +export function getTitleFromError(value: unknown): string | undefined { + if (value == null) { + return undefined; + } + + let message: unknown = value; + if (typeof value === 'object' && 'message' in value) { + message = (value as {message?: unknown}).message; + } + + if (typeof message === 'string') { + return message || undefined; + } + + try { + return JSON.stringify(message); + } catch { + /** + * If no JSON global is available or serialization fails, + * fall back to string conversion + */ + return String(message); + } +} + +/** + * Extracts an error type name from an unknown value. + * + * @param value - Any already-safe value prepared by the caller + * @returns The error name string, or undefined if absent or empty + */ +export function getTypeFromError(value: unknown): HawkJavaScriptEvent['type'] | undefined { + if (typeof value !== 'object' || value === null || !('name' in value)) { + return undefined; + } + + const name = (value as {name?: unknown}).name; + + return typeof name === 'string' && name ? name : undefined; +} + +/** + * Extracts raw error data and event-level fallbacks 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 Raw error source with optional event-level fallback values + */ +export function getErrorFromErrorEvent(event: ErrorEvent | PromiseRejectionEvent): ErrorSource { + if (event.type === 'error') { + event = event as ErrorEvent; + + return { + rawError: event.error, + fallbackTitle: event.message + ? (event.filename ? `'${event.message}' at ${event.filename}:${event.lineno}:${event.colno}` : event.message) + : undefined, + }; + } + + if (event.type === 'unhandledrejection') { + event = event as PromiseRejectionEvent; + + return { + rawError: event.reason, + fallbackType: 'UnhandledRejection', + }; + } + + return { rawError: undefined }; +} diff --git a/packages/javascript/tests/utils/error.test.ts b/packages/javascript/tests/utils/error.test.ts new file mode 100644 index 0000000..2aa647a --- /dev/null +++ b/packages/javascript/tests/utils/error.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Sanitizer } from '@hawk.so/core'; +import { getErrorFromErrorEvent, getTitleFromError, getTypeFromError } 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 raw error without fallbacks', () => { + const error = new Error('Test error'); + const event = new ErrorEvent('error', { error }); + const result = getErrorFromErrorEvent(event); + + expect(result.rawError).toBe(error); + expect(result.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).toBeUndefined(); + }); + + it('should preserve DOMException instance for downstream normalization', () => { + const error = new DOMException('Network error', 'NetworkError'); + const event = new ErrorEvent('error', { error }); + const result = getErrorFromErrorEvent(event); + + expect(result.rawError).toBe(error); + expect(result.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).toBeUndefined(); + }); + + 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.fallbackTitle).toContain('Script error.'); + expect(result.fallbackTitle).toContain('app.js:10:5'); + expect(result.fallbackType).toBeUndefined(); + }); + + it('should omit fallback title when event.error and message are both absent', () => { + const event = new ErrorEvent('error', { message: '' }); + const result = getErrorFromErrorEvent(event); + + expect(result.rawError).toBeNull(); + expect(result.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).toBeUndefined(); + }); + }); + + describe('PromiseRejectionEvent', () => { + it('should capture Error reason and rejection fallback type', () => { + 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.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).toBe('UnhandledRejection'); + }); + + 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.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).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.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).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.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).toBe('UnhandledRejection'); + }); + + 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.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).toBe('UnhandledRejection'); + }); + }); + + describe('fallback branch', () => { + it('should return an empty error source for unsupported event types', () => { + const event = { type: 'custom' } as ErrorEvent | PromiseRejectionEvent; + const result = getErrorFromErrorEvent(event); + + expect(result.rawError).toBeUndefined(); + expect(result.fallbackTitle).toBeUndefined(); + expect(result.fallbackType).toBeUndefined(); + }); + }); + + 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); + }); + }); +}); + +describe('getTitleFromError', () => { + it('should return Error message from sanitized Error', () => { + const sanitizedError = Sanitizer.sanitize(new Error('Test error')); + + expect(getTitleFromError(sanitizedError)).toBe('Test error'); + }); + + it('should return class instance placeholder for sanitized DOMException', () => { + const sanitizedError = Sanitizer.sanitize(new DOMException('Network error', 'NetworkError')); + + expect(getTitleFromError(sanitizedError)).toBe(''); + }); + + it('should serialize sanitized plain objects', () => { + const sanitizedError = Sanitizer.sanitize({ code: 'ERR_001', details: 'Something went wrong' }); + + expect(getTitleFromError(sanitizedError)).toBe('{"code":"ERR_001","details":"Something went wrong"}'); + }); + + it('should handle circular references after sanitization', () => { + const circularObj: Record = { name: 'test' }; + circularObj.self = circularObj; + const sanitizedError = Sanitizer.sanitize(circularObj); + + expect(getTitleFromError(sanitizedError)).toContain(''); + }); + + it('should return undefined for nullish values', () => { + expect(getTitleFromError(undefined)).toBeUndefined(); + expect(getTitleFromError(null)).toBeUndefined(); + }); +}); + +describe('getTypeFromError', () => { + it('should return Error name from sanitized Error', () => { + const sanitizedError = Sanitizer.sanitize(new Error('Test error')); + + expect(getTypeFromError(sanitizedError)).toBe('Error'); + }); + + it('should return undefined for sanitized strings', () => { + expect(getTypeFromError(Sanitizer.sanitize('Something went wrong'))).toBeUndefined(); + }); + + it('should return undefined when name is absent', () => { + expect(getTypeFromError(Sanitizer.sanitize({ code: 'ERR_001' }))).toBeUndefined(); + }); +});