From 28eb7b3cf05e234be896e3242ca9da4b25e8a724 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Mon, 9 Mar 2026 19:00:32 +0300 Subject: [PATCH 1/9] fix: preserve content when promise is rejected with plain object --- .../javascript/src/addons/consoleCatcher.ts | 21 ++++++++++- packages/javascript/src/catcher.ts | 18 ++-------- packages/javascript/src/utils/event.ts | 36 +++++++++++++++++++ 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/packages/javascript/src/addons/consoleCatcher.ts b/packages/javascript/src/addons/consoleCatcher.ts index 29519eaa..17a926a7 100644 --- a/packages/javascript/src/addons/consoleCatcher.ts +++ b/packages/javascript/src/addons/consoleCatcher.ts @@ -189,6 +189,25 @@ export class ConsoleCatcher { this.consoleOutput.push(logEvent); } + /** + * Converts a promise rejection reason to a string message. + * + * String(obj) gives "[object Object]" and JSON.stringify("str") + * adds unwanted quotes. + * + * @param reason - The rejection reason from PromiseRejectionEvent + */ + private stringifyReason(reason: unknown): string { + if (reason instanceof Error) { + return reason.message; + } + if (typeof reason === 'string') { + return reason; + } + + return JSON.stringify(Sanitizer.sanitize(reason)); + } + /** * Creates a console log event from an error or promise rejection * @@ -212,7 +231,7 @@ export class ConsoleCatcher { method: 'error', timestamp: new Date(), type: 'UnhandledRejection', - message: event.reason?.message || String(event.reason), + message: this.stringifyReason(event.reason), stack: event.reason?.stack || '', fileLine: '', }; diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index 822d974c..e7de0295 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -14,7 +14,7 @@ import type { } from '@hawk.so/types'; import type { JavaScriptCatcherIntegrations } from './types/integrations'; import { EventRejectedError } from './errors'; -import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; +import { isErrorProcessed, markErrorAsProcessed, getErrorFromEvent } from './utils/event'; import { BrowserRandomGenerator } from './utils/random'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; @@ -331,21 +331,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 = getErrorFromEvent(event); void this.formatAndSend(error); } diff --git a/packages/javascript/src/utils/event.ts b/packages/javascript/src/utils/event.ts index 63741533..a44bec6d 100644 --- a/packages/javascript/src/utils/event.ts +++ b/packages/javascript/src/utils/event.ts @@ -1,4 +1,5 @@ import { log } from '@hawk.so/core'; +import Sanitizer from '../modules/sanitizer'; /** * Symbol to mark error as processed by Hawk @@ -44,3 +45,38 @@ export function markErrorAsProcessed(error: unknown): void { log('Failed to mark error as processed', 'error', e); } } + +/** + * Extracts and normalizes error from ErrorEvent or PromiseRejectionEvent + * + * @param event - The error or promise rejection event + */ +export function getErrorFromEvent(event: ErrorEvent | PromiseRejectionEvent): Error | string { + /** + * 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; + } + + /** + * Case when error rejected with an object + * Using a string instead of wrapping in Error is more natural, + * it doesn't fake the backtrace, also prefix added for dashboard readability + */ + if (error instanceof Object && !(error instanceof Error) && event instanceof PromiseRejectionEvent) { + // Extra sanitize is needed to handle objects with circular references before JSON.stringify + error = `Promise rejected with ${JSON.stringify(Sanitizer.sanitize(error))}`; + } + + return Sanitizer.sanitize(error); +} From 033bdfb6a5fc760fe07a7ee261f7a8af31aa6ac9 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Wed, 11 Mar 2026 20:03:48 +0300 Subject: [PATCH 2/9] fix: move stringify reason method to utils --- .../javascript/src/addons/consoleCatcher.ts | 22 ++----------------- packages/javascript/src/utils/event.ts | 19 ++++++++++++++++ 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/javascript/src/addons/consoleCatcher.ts b/packages/javascript/src/addons/consoleCatcher.ts index 17a926a7..7d7750d7 100644 --- a/packages/javascript/src/addons/consoleCatcher.ts +++ b/packages/javascript/src/addons/consoleCatcher.ts @@ -3,6 +3,7 @@ */ import type { ConsoleLogEvent } from '@hawk.so/types'; import Sanitizer from '../modules/sanitizer'; +import { stringifyRejectionReason } from 'src/utils/event'; /** * Maximum number of console logs to store @@ -189,25 +190,6 @@ export class ConsoleCatcher { this.consoleOutput.push(logEvent); } - /** - * Converts a promise rejection reason to a string message. - * - * String(obj) gives "[object Object]" and JSON.stringify("str") - * adds unwanted quotes. - * - * @param reason - The rejection reason from PromiseRejectionEvent - */ - private stringifyReason(reason: unknown): string { - if (reason instanceof Error) { - return reason.message; - } - if (typeof reason === 'string') { - return reason; - } - - return JSON.stringify(Sanitizer.sanitize(reason)); - } - /** * Creates a console log event from an error or promise rejection * @@ -231,7 +213,7 @@ export class ConsoleCatcher { method: 'error', timestamp: new Date(), type: 'UnhandledRejection', - message: this.stringifyReason(event.reason), + message: stringifyRejectionReason(event.reason), stack: event.reason?.stack || '', fileLine: '', }; diff --git a/packages/javascript/src/utils/event.ts b/packages/javascript/src/utils/event.ts index a44bec6d..058f2ec4 100644 --- a/packages/javascript/src/utils/event.ts +++ b/packages/javascript/src/utils/event.ts @@ -80,3 +80,22 @@ export function getErrorFromEvent(event: ErrorEvent | PromiseRejectionEvent): Er return Sanitizer.sanitize(error); } + +/** + * Converts a promise rejection reason to a string message. + * + * String(obj) gives "[object Object]" and JSON.stringify("str") + * adds unwanted quotes. + * + * @param reason - The rejection reason from PromiseRejectionEvent + */ +export function stringifyRejectionReason(reason: unknown): string { + if (reason instanceof Error) { + return reason.message; + } + if (typeof reason === 'string') { + return reason; + } + + return JSON.stringify(Sanitizer.sanitize(reason)); +} From e2f8a61ab31911cb946a79e142f9bdee8ed97ac5 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Wed, 11 Mar 2026 17:37:09 +0300 Subject: [PATCH 3/9] fix: fixed import --- packages/javascript/src/addons/consoleCatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/javascript/src/addons/consoleCatcher.ts b/packages/javascript/src/addons/consoleCatcher.ts index 7d7750d7..efe7ef3b 100644 --- a/packages/javascript/src/addons/consoleCatcher.ts +++ b/packages/javascript/src/addons/consoleCatcher.ts @@ -3,7 +3,7 @@ */ import type { ConsoleLogEvent } from '@hawk.so/types'; import Sanitizer from '../modules/sanitizer'; -import { stringifyRejectionReason } from 'src/utils/event'; +import { stringifyRejectionReason } from '../utils/event'; /** * Maximum number of console logs to store From 47c696728bae733ada390f7a6373c4ad9e88bae4 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Wed, 11 Mar 2026 17:38:33 +0300 Subject: [PATCH 4/9] test: tests added for getErrorFromEvent --- packages/javascript/tests/utils/event.test.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 packages/javascript/tests/utils/event.test.ts diff --git a/packages/javascript/tests/utils/event.test.ts b/packages/javascript/tests/utils/event.test.ts new file mode 100644 index 00000000..55ec8a15 --- /dev/null +++ b/packages/javascript/tests/utils/event.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getErrorFromEvent } from '../../src/utils/event'; + +vi.mock('@hawk.so/core', () => ({ log: vi.fn(), isLoggerSet: vi.fn(() => true), setLogger: vi.fn() })); + +vi.mock('../../src/modules/sanitizer', () => ({ + default: { + sanitize: vi.fn((data) => data), + }, +})); + +import Sanitizer from '../../src/modules/sanitizer'; + +describe('getErrorFromEvent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('ErrorEvent', () => { + it('should return the Error when event.error is an Error instance', () => { + const error = new Error('Test error'); + const event = new ErrorEvent('error', { error }); + + const result = getErrorFromEvent(event); + + expect(result).toBe(error); + expect(Sanitizer.sanitize).toHaveBeenCalledWith(error); + }); + + it('should return the DOMException when event.error is a DOMException', () => { + const error = new DOMException('Network error', 'NetworkError'); + const event = new ErrorEvent('error', { error }); + + const result = getErrorFromEvent(event); + + expect(result).toBe(error); + expect(Sanitizer.sanitize).toHaveBeenCalledWith(error); + }); + + it('should return the message when event.error is not provided and message is a string', () => { + const event = new ErrorEvent('error', { message: 'Script error.' }); + + const result = getErrorFromEvent(event); + + expect(result).toBe('Script error.'); + expect(Sanitizer.sanitize).toHaveBeenCalledWith('Script error.'); + }); + + it('should return empty string when event.error is not provided and message is empty', () => { + const event = new ErrorEvent('error', { message: '' }); + + const result = getErrorFromEvent(event); + + expect(result).toBe(''); + expect(Sanitizer.sanitize).toHaveBeenCalledWith(''); + }); + }); + + describe('PromiseRejectionEvent', () => { + it('should return the Error when event.reason is an Error instance', () => { + const reason = new Error('Promise rejected'); + const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); + + const result = getErrorFromEvent(event); + + expect(result).toBe(reason); + expect(Sanitizer.sanitize).toHaveBeenCalledWith(reason); + }); + + it('should return the string when event.reason is a string', () => { + const reason = 'Promise rejected with string'; + const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); + + const result = getErrorFromEvent(event); + + expect(result).toBe(reason); + expect(Sanitizer.sanitize).toHaveBeenCalledWith(reason); + }); + + it('should return stringified object when event.reason is a plain object', () => { + const reason = { code: 'ERR_001', details: 'Something went wrong' }; + const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); + + const result = getErrorFromEvent(event); + + expect(result).toBe('Promise rejected with {"code":"ERR_001","details":"Something went wrong"}'); + }); + + it('should return undefined when event.reason is not provided', () => { + const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: undefined }); + + const result = getErrorFromEvent(event); + + expect(result).toBeUndefined(); + expect(Sanitizer.sanitize).toHaveBeenCalledWith(undefined); + }); + + it('should return null when event.reason is null', () => { + const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: null }); + + const result = getErrorFromEvent(event); + + expect(result).toBeNull(); + expect(Sanitizer.sanitize).toHaveBeenCalledWith(null); + }); + + it('should handle circular references in object reason', () => { + vi.mocked(Sanitizer.sanitize).mockImplementation((data) => { + if (data !== null && typeof data === 'object') { + const seen = new WeakSet(); + const sanitize = (obj: unknown): unknown => { + if (obj !== null && typeof obj === 'object') { + if (seen.has(obj as object)) { + return ''; + } + seen.add(obj as object); + if (Array.isArray(obj)) { + return obj.map(sanitize); + } + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = sanitize(value); + } + return result; + } + return obj; + }; + return sanitize(data); + } + return data; + }); + + const circularObj: Record = { name: 'test' }; + circularObj.self = circularObj; + + const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: circularObj }); + + const result = getErrorFromEvent(event); + + expect(result).toContain('Promise rejected with'); + expect(result).toContain(''); + }); + }); +}); From ad0313a2f1f6dd3419b3b4942e3d5032f09f1102 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Wed, 11 Mar 2026 17:55:25 +0300 Subject: [PATCH 5/9] test: remove sanitizer mocking --- packages/javascript/tests/utils/event.test.ts | 41 +------------------ 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/packages/javascript/tests/utils/event.test.ts b/packages/javascript/tests/utils/event.test.ts index 55ec8a15..a221a6b1 100644 --- a/packages/javascript/tests/utils/event.test.ts +++ b/packages/javascript/tests/utils/event.test.ts @@ -3,12 +3,6 @@ import { getErrorFromEvent } from '../../src/utils/event'; vi.mock('@hawk.so/core', () => ({ log: vi.fn(), isLoggerSet: vi.fn(() => true), setLogger: vi.fn() })); -vi.mock('../../src/modules/sanitizer', () => ({ - default: { - sanitize: vi.fn((data) => data), - }, -})); - import Sanitizer from '../../src/modules/sanitizer'; describe('getErrorFromEvent', () => { @@ -24,7 +18,6 @@ describe('getErrorFromEvent', () => { const result = getErrorFromEvent(event); expect(result).toBe(error); - expect(Sanitizer.sanitize).toHaveBeenCalledWith(error); }); it('should return the DOMException when event.error is a DOMException', () => { @@ -33,8 +26,7 @@ describe('getErrorFromEvent', () => { const result = getErrorFromEvent(event); - expect(result).toBe(error); - expect(Sanitizer.sanitize).toHaveBeenCalledWith(error); + expect(result).toBe(''); }); it('should return the message when event.error is not provided and message is a string', () => { @@ -43,7 +35,6 @@ describe('getErrorFromEvent', () => { const result = getErrorFromEvent(event); expect(result).toBe('Script error.'); - expect(Sanitizer.sanitize).toHaveBeenCalledWith('Script error.'); }); it('should return empty string when event.error is not provided and message is empty', () => { @@ -52,7 +43,6 @@ describe('getErrorFromEvent', () => { const result = getErrorFromEvent(event); expect(result).toBe(''); - expect(Sanitizer.sanitize).toHaveBeenCalledWith(''); }); }); @@ -64,7 +54,6 @@ describe('getErrorFromEvent', () => { const result = getErrorFromEvent(event); expect(result).toBe(reason); - expect(Sanitizer.sanitize).toHaveBeenCalledWith(reason); }); it('should return the string when event.reason is a string', () => { @@ -74,7 +63,6 @@ describe('getErrorFromEvent', () => { const result = getErrorFromEvent(event); expect(result).toBe(reason); - expect(Sanitizer.sanitize).toHaveBeenCalledWith(reason); }); it('should return stringified object when event.reason is a plain object', () => { @@ -92,7 +80,6 @@ describe('getErrorFromEvent', () => { const result = getErrorFromEvent(event); expect(result).toBeUndefined(); - expect(Sanitizer.sanitize).toHaveBeenCalledWith(undefined); }); it('should return null when event.reason is null', () => { @@ -101,35 +88,9 @@ describe('getErrorFromEvent', () => { const result = getErrorFromEvent(event); expect(result).toBeNull(); - expect(Sanitizer.sanitize).toHaveBeenCalledWith(null); }); it('should handle circular references in object reason', () => { - vi.mocked(Sanitizer.sanitize).mockImplementation((data) => { - if (data !== null && typeof data === 'object') { - const seen = new WeakSet(); - const sanitize = (obj: unknown): unknown => { - if (obj !== null && typeof obj === 'object') { - if (seen.has(obj as object)) { - return ''; - } - seen.add(obj as object); - if (Array.isArray(obj)) { - return obj.map(sanitize); - } - const result: Record = {}; - for (const [key, value] of Object.entries(obj)) { - result[key] = sanitize(value); - } - return result; - } - return obj; - }; - return sanitize(data); - } - return data; - }); - const circularObj: Record = { name: 'test' }; circularObj.self = circularObj; From c33558690dc1d308b975d6376c8884b20b0609ea Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Wed, 11 Mar 2026 18:28:44 +0300 Subject: [PATCH 6/9] fix: extract error utilities --- packages/javascript/src/catcher.ts | 5 +- packages/javascript/src/utils/error.ts | 55 +++++++++++++++++++ packages/javascript/src/utils/event.ts | 55 ------------------- .../utils/{event.test.ts => error.test.ts} | 24 ++++---- 4 files changed, 70 insertions(+), 69 deletions(-) create mode 100644 packages/javascript/src/utils/error.ts rename packages/javascript/tests/utils/{event.test.ts => error.test.ts} (83%) diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index e7de0295..16ec3650 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -14,7 +14,8 @@ import type { } from '@hawk.so/types'; import type { JavaScriptCatcherIntegrations } from './types/integrations'; import { EventRejectedError } from './errors'; -import { isErrorProcessed, markErrorAsProcessed, getErrorFromEvent } from './utils/event'; +import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; +import { getErrorFromErrorEvent } from './utils/error'; import { BrowserRandomGenerator } from './utils/random'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; @@ -331,7 +332,7 @@ export default class Catcher { this.consoleCatcher!.addErrorEvent(event); } - const error = getErrorFromEvent(event); + const error = getErrorFromErrorEvent(event); void this.formatAndSend(error); } diff --git a/packages/javascript/src/utils/error.ts b/packages/javascript/src/utils/error.ts new file mode 100644 index 00000000..9182ecef --- /dev/null +++ b/packages/javascript/src/utils/error.ts @@ -0,0 +1,55 @@ +import Sanitizer from '../modules/sanitizer'; + +/** + * Extracts and normalizes error from ErrorEvent or PromiseRejectionEvent + * + * @param event - The error or promise rejection event + */ +export function getErrorFromErrorEvent(event: ErrorEvent | PromiseRejectionEvent): Error | string { + /** + * 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; + } + + /** + * Case when error rejected with an object + * Using a string instead of wrapping in Error is more natural, + * it doesn't fake the backtrace, also prefix added for dashboard readability + */ + if (error instanceof Object && !(error instanceof Error) && event instanceof PromiseRejectionEvent) { + // Extra sanitize is needed to handle objects with circular references before JSON.stringify + error = `Promise rejected with ${JSON.stringify(Sanitizer.sanitize(error))}`; + } + + return Sanitizer.sanitize(error); +} + +/** + * Converts a promise rejection reason to a string message. + * + * String(obj) gives "[object Object]" and JSON.stringify("str") + * adds unwanted quotes. + * + * @param reason - The rejection reason from PromiseRejectionEvent + */ +export function stringifyRejectionReason(reason: unknown): string { + if (reason instanceof Error) { + return reason.message; + } + if (typeof reason === 'string') { + return reason; + } + + return JSON.stringify(Sanitizer.sanitize(reason)); +} diff --git a/packages/javascript/src/utils/event.ts b/packages/javascript/src/utils/event.ts index 058f2ec4..63741533 100644 --- a/packages/javascript/src/utils/event.ts +++ b/packages/javascript/src/utils/event.ts @@ -1,5 +1,4 @@ import { log } from '@hawk.so/core'; -import Sanitizer from '../modules/sanitizer'; /** * Symbol to mark error as processed by Hawk @@ -45,57 +44,3 @@ export function markErrorAsProcessed(error: unknown): void { log('Failed to mark error as processed', 'error', e); } } - -/** - * Extracts and normalizes error from ErrorEvent or PromiseRejectionEvent - * - * @param event - The error or promise rejection event - */ -export function getErrorFromEvent(event: ErrorEvent | PromiseRejectionEvent): Error | string { - /** - * 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; - } - - /** - * Case when error rejected with an object - * Using a string instead of wrapping in Error is more natural, - * it doesn't fake the backtrace, also prefix added for dashboard readability - */ - if (error instanceof Object && !(error instanceof Error) && event instanceof PromiseRejectionEvent) { - // Extra sanitize is needed to handle objects with circular references before JSON.stringify - error = `Promise rejected with ${JSON.stringify(Sanitizer.sanitize(error))}`; - } - - return Sanitizer.sanitize(error); -} - -/** - * Converts a promise rejection reason to a string message. - * - * String(obj) gives "[object Object]" and JSON.stringify("str") - * adds unwanted quotes. - * - * @param reason - The rejection reason from PromiseRejectionEvent - */ -export function stringifyRejectionReason(reason: unknown): string { - if (reason instanceof Error) { - return reason.message; - } - if (typeof reason === 'string') { - return reason; - } - - return JSON.stringify(Sanitizer.sanitize(reason)); -} diff --git a/packages/javascript/tests/utils/event.test.ts b/packages/javascript/tests/utils/error.test.ts similarity index 83% rename from packages/javascript/tests/utils/event.test.ts rename to packages/javascript/tests/utils/error.test.ts index a221a6b1..64e38fce 100644 --- a/packages/javascript/tests/utils/event.test.ts +++ b/packages/javascript/tests/utils/error.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getErrorFromEvent } from '../../src/utils/event'; +import { getErrorFromErrorEvent } from '../../src/utils/error'; vi.mock('@hawk.so/core', () => ({ log: vi.fn(), isLoggerSet: vi.fn(() => true), setLogger: vi.fn() })); import Sanitizer from '../../src/modules/sanitizer'; -describe('getErrorFromEvent', () => { +describe('getErrorFromErrorEvent', () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -15,7 +15,7 @@ describe('getErrorFromEvent', () => { const error = new Error('Test error'); const event = new ErrorEvent('error', { error }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBe(error); }); @@ -24,7 +24,7 @@ describe('getErrorFromEvent', () => { const error = new DOMException('Network error', 'NetworkError'); const event = new ErrorEvent('error', { error }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBe(''); }); @@ -32,7 +32,7 @@ describe('getErrorFromEvent', () => { it('should return the message when event.error is not provided and message is a string', () => { const event = new ErrorEvent('error', { message: 'Script error.' }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBe('Script error.'); }); @@ -40,7 +40,7 @@ describe('getErrorFromEvent', () => { it('should return empty string when event.error is not provided and message is empty', () => { const event = new ErrorEvent('error', { message: '' }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBe(''); }); @@ -51,7 +51,7 @@ describe('getErrorFromEvent', () => { const reason = new Error('Promise rejected'); const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBe(reason); }); @@ -60,7 +60,7 @@ describe('getErrorFromEvent', () => { const reason = 'Promise rejected with string'; const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBe(reason); }); @@ -69,7 +69,7 @@ describe('getErrorFromEvent', () => { const reason = { code: 'ERR_001', details: 'Something went wrong' }; const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBe('Promise rejected with {"code":"ERR_001","details":"Something went wrong"}'); }); @@ -77,7 +77,7 @@ describe('getErrorFromEvent', () => { it('should return undefined when event.reason is not provided', () => { const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: undefined }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBeUndefined(); }); @@ -85,7 +85,7 @@ describe('getErrorFromEvent', () => { it('should return null when event.reason is null', () => { const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: null }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toBeNull(); }); @@ -96,7 +96,7 @@ describe('getErrorFromEvent', () => { const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: circularObj }); - const result = getErrorFromEvent(event); + const result = getErrorFromErrorEvent(event); expect(result).toContain('Promise rejected with'); expect(result).toContain(''); From b2c89814d1ac6ad2da6509da1a2530355a31d903 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Fri, 10 Apr 2026 14:22:46 +0300 Subject: [PATCH 7/9] refactor(javascript): normalize browser error event extraction --- .../javascript/src/addons/consoleCatcher.ts | 14 +- packages/javascript/src/catcher.ts | 81 ++++-------- packages/javascript/src/utils/error.ts | 122 ++++++++++++------ .../tests/catcher.global-handlers.test.ts | 2 +- packages/javascript/tests/utils/error.test.ts | 116 +++++++++++------ 5 files changed, 195 insertions(+), 140 deletions(-) diff --git a/packages/javascript/src/addons/consoleCatcher.ts b/packages/javascript/src/addons/consoleCatcher.ts index efe7ef3b..f131bec4 100644 --- a/packages/javascript/src/addons/consoleCatcher.ts +++ b/packages/javascript/src/addons/consoleCatcher.ts @@ -3,7 +3,7 @@ */ import type { ConsoleLogEvent } from '@hawk.so/types'; import Sanitizer from '../modules/sanitizer'; -import { stringifyRejectionReason } from '../utils/event'; +import { getErrorFromErrorEvent } from '../utils/error'; /** * Maximum number of console logs to store @@ -196,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}` : '', @@ -213,8 +215,8 @@ export class ConsoleCatcher { method: 'error', timestamp: new Date(), type: 'UnhandledRejection', - message: stringifyRejectionReason(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 16ec3650..c0e6dd67 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -10,12 +10,13 @@ import type { EventContext, JavaScriptAddons, Json, - VueIntegrationAddons + VueIntegrationAddons, } from '@hawk.so/types'; import type { JavaScriptCatcherIntegrations } from './types/integrations'; import { EventRejectedError } from './errors'; import { isErrorProcessed, markErrorAsProcessed } from './utils/event'; -import { getErrorFromErrorEvent } from './utils/error'; +import type { CapturedError } from './utils/error'; +import { fillCapturedError, getErrorFromErrorEvent } from './utils/error'; import { BrowserRandomGenerator } from './utils/random'; import { ConsoleCatcher } from './addons/consoleCatcher'; import { BreadcrumbManager } from './addons/breadcrumbs'; @@ -222,7 +223,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(fillCapturedError(message), undefined, context); } /** @@ -234,7 +235,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(fillCapturedError(error), addons); } /** @@ -247,7 +248,7 @@ export default class Catcher { this.vue = new VueIntegration( vue, (error: Error, addons: VueIntegrationAddons) => { - void this.formatAndSend(error, { + void this.formatAndSend(fillCapturedError(error), { vue: addons, }); }, @@ -345,13 +346,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) { /** @@ -359,7 +360,7 @@ export default class Catcher { */ return; } else { - markErrorAsProcessed(error); + markErrorAsProcessed(error.rawError); } const errorFormatted = await this.prepareErrorFormatted(error, context); @@ -402,16 +403,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, }; @@ -463,44 +465,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 null; - } - - return (error as Error).name; - } - /** * Release version */ @@ -590,7 +554,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); /** @@ -613,9 +577,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; @@ -649,9 +613,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; } @@ -672,7 +636,10 @@ export default class Catcher { * @param errorFormatted - Hawk event prepared for sending * @param integrationAddons - extra addons */ - private appendIntegrationAddons(errorFormatted: CatcherMessage, integrationAddons: JavaScriptCatcherIntegrations): void { + private appendIntegrationAddons( + errorFormatted: CatcherMessage, + integrationAddons: JavaScriptCatcherIntegrations + ): void { Object.assign(errorFormatted.payload.addons, integrationAddons); } } diff --git a/packages/javascript/src/utils/error.ts b/packages/javascript/src/utils/error.ts index 9182ecef..a65f3e98 100644 --- a/packages/javascript/src/utils/error.ts +++ b/packages/javascript/src/utils/error.ts @@ -1,55 +1,105 @@ import Sanitizer from '../modules/sanitizer'; /** - * Extracts and normalizes error from ErrorEvent or PromiseRejectionEvent + * Represents a captured error in a normalized form. * - * @param event - The error or promise rejection event + * 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 null if unknown */ + type: string | null; + /** 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 safeError - Sanitized error value (any shape) + * @returns A non-empty string title, or undefined if the value is nullish or empty */ -export function getErrorFromErrorEvent(event: ErrorEvent | PromiseRejectionEvent): Error | string { - /** - * 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; +function getTitleFromError(safeError: unknown): string | undefined { + if (safeError == null) { + return undefined; } - /** - * Case when error rejected with an object - * Using a string instead of wrapping in Error is more natural, - * it doesn't fake the backtrace, also prefix added for dashboard readability - */ - if (error instanceof Object && !(error instanceof Error) && event instanceof PromiseRejectionEvent) { - // Extra sanitize is needed to handle objects with circular references before JSON.stringify - error = `Promise rejected with ${JSON.stringify(Sanitizer.sanitize(error))}`; + const message = + typeof safeError === 'object' && 'message' in safeError ? (safeError as Error).message : safeError; + + if (typeof message === 'string') { + return message || undefined; } - return Sanitizer.sanitize(error); + 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 safeError - Sanitized error value (any shape) + * @returns The error name string, or undefined if absent or empty + */ +function getTypeFromError(safeError: unknown): string | undefined { + const name = (safeError as Error)?.name; + + return name || undefined; } /** - * Converts a promise rejection reason to a string message. + * Constructs a CapturedError from an unknown error value and optional fallbacks. * - * String(obj) gives "[object Object]" and JSON.stringify("str") - * adds unwanted quotes. + * @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 fillCapturedError( + error: unknown, + fallbackValues: { title?: string; type?: string } = {} +): CapturedError { + const sanitizedError = Sanitizer.sanitize(error); + + return { + title: getTitleFromError(sanitizedError) || fallbackValues.title || '', + type: getTypeFromError(sanitizedError) || fallbackValues.type || null, + 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 reason - The rejection reason from PromiseRejectionEvent + * @param event - The error or promise rejection event + * @returns A normalized CapturedError object */ -export function stringifyRejectionReason(reason: unknown): string { - if (reason instanceof Error) { - return reason.message; +export function getErrorFromErrorEvent(event: ErrorEvent | PromiseRejectionEvent): CapturedError { + if (event.type === 'error') { + event = event as ErrorEvent; + + return fillCapturedError(event.error, { + title: event.message && `'${event.message}' at ${event.filename || ''}:${event.lineno}:${event.colno}`, + }); } - if (typeof reason === 'string') { - return reason; + + if (event.type === 'unhandledrejection') { + event = event as PromiseRejectionEvent; + + return fillCapturedError(event.reason, { + type: 'UnhandledRejection', + }); } - return JSON.stringify(Sanitizer.sanitize(reason)); + return fillCapturedError(undefined); } diff --git a/packages/javascript/tests/catcher.global-handlers.test.ts b/packages/javascript/tests/catcher.global-handlers.test.ts index 34c18108..660fffcf 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.' at :0:0"); }); 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 index 64e38fce..0d960516 100644 --- a/packages/javascript/tests/utils/error.test.ts +++ b/packages/javascript/tests/utils/error.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { getErrorFromErrorEvent } from '../../src/utils/error'; -vi.mock('@hawk.so/core', () => ({ log: vi.fn(), isLoggerSet: vi.fn(() => true), setLogger: vi.fn() })); - -import Sanitizer from '../../src/modules/sanitizer'; +vi.mock('@hawk.so/core', () => ({ + log: vi.fn(), + isLoggerSet: vi.fn(() => true), + setLogger: vi.fn(), +})); describe('getErrorFromErrorEvent', () => { beforeEach(() => { @@ -11,95 +13,129 @@ describe('getErrorFromErrorEvent', () => { }); describe('ErrorEvent', () => { - it('should return the Error when event.error is an Error instance', () => { + 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).toBe(error); + expect(result.rawError).toBe(error); + expect(result.title).toBe('Test error'); + expect(result.type).toBe('Error'); }); - it('should return the DOMException when event.error is a DOMException', () => { + 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).toBe(''); + expect(result.rawError).toBe(error); + expect(result.title).toBe(''); }); - it('should return the message when event.error is not provided and message is a string', () => { - const event = new ErrorEvent('error', { message: 'Script error.' }); - + 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).toBe('Script error.'); + expect(result.rawError).toBeNull(); + expect(result.title).toContain('Script error.'); + expect(result.title).toContain('app.js:10:5'); }); - it('should return empty string when event.error is not provided and message is empty', () => { + 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).toBe(''); + expect(result.title).toBe(''); }); }); describe('PromiseRejectionEvent', () => { - it('should return the Error when event.reason is an Error instance', () => { + it('should capture Error reason with correct fields', () => { const reason = new Error('Promise rejected'); - const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); - + const event = new PromiseRejectionEvent('unhandledrejection', { + promise: Promise.resolve(), + reason, + }); const result = getErrorFromErrorEvent(event); - expect(result).toBe(reason); + expect(result.rawError).toBe(reason); + expect(result.title).toBe('Promise rejected'); + expect(result.type).toBe('Error'); }); - it('should return the string when event.reason is a string', () => { - const reason = 'Promise rejected with string'; - const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason }); - + 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).toBe(reason); + expect(result.rawError).toBe(reason); + expect(result.title).toBe('Something went wrong'); + expect(result.type).toBe('UnhandledRejection'); }); - it('should return stringified object when event.reason is a plain object', () => { + 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).toBe('Promise rejected with {"code":"ERR_001","details":"Something went wrong"}'); + expect(result.rawError).toBe(reason); + expect(result.title).toBe('{"code":"ERR_001","details":"Something went wrong"}'); + expect(result.type).toBe('UnhandledRejection'); }); - it('should return undefined when event.reason is not provided', () => { - const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: undefined }); - + it('should handle undefined reason', () => { + const event = new PromiseRejectionEvent('unhandledrejection', { + promise: Promise.resolve(), + reason: undefined, + }); const result = getErrorFromErrorEvent(event); - expect(result).toBeUndefined(); + expect(result.rawError).toBeUndefined(); + expect(result.title).toBe(''); }); - it('should return null when event.reason is null', () => { - const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: null }); - + it('should handle null reason', () => { + const event = new PromiseRejectionEvent('unhandledrejection', { + promise: Promise.resolve(), + reason: null, + }); const result = getErrorFromErrorEvent(event); - expect(result).toBeNull(); + 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); - const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: circularObj }); + expect(result.rawError).toBe(circularObj); + expect(result.title).toContain(''); + }); + }); - const result = getErrorFromErrorEvent(event); + 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(result).toContain('Promise rejected with'); - expect(result).toContain(''); + expect(result1.rawError).toBe(result2.rawError); + expect(result1.rawError).toBe(error); }); }); }); From 93c21d33add2076812a832ec5e8510e0606e5248 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Sat, 11 Apr 2026 13:58:14 +0300 Subject: [PATCH 8/9] fix: tests and lint fixed --- packages/javascript/src/catcher.ts | 2 +- packages/javascript/src/utils/error.ts | 2 +- packages/javascript/tests/utils/error.test.ts | 15 ++++++++++----- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index c03f8677..dd5be3c0 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -31,7 +31,7 @@ import { import { HawkLocalStorage } from './storages/hawk-local-storage'; import { createBrowserLogger } from './logger/logger'; import { BrowserRandomGenerator } from './utils/random'; -import { CapturedError, fillCapturedError, getErrorFromErrorEvent } from './utils/error'; +import { type CapturedError, fillCapturedError, getErrorFromErrorEvent } from './utils/error'; /** * Allow to use global VERSION, that will be overwritten by Webpack diff --git a/packages/javascript/src/utils/error.ts b/packages/javascript/src/utils/error.ts index ce918c2e..b9755c9b 100644 --- a/packages/javascript/src/utils/error.ts +++ b/packages/javascript/src/utils/error.ts @@ -1,4 +1,4 @@ -import { HawkJavaScriptEvent } from '@/types'; +import type { HawkJavaScriptEvent } from '@/types'; import { Sanitizer } from '@hawk.so/core'; /** diff --git a/packages/javascript/tests/utils/error.test.ts b/packages/javascript/tests/utils/error.test.ts index 0d960516..9de57285 100644 --- a/packages/javascript/tests/utils/error.test.ts +++ b/packages/javascript/tests/utils/error.test.ts @@ -1,11 +1,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { getErrorFromErrorEvent } from '../../src/utils/error'; -vi.mock('@hawk.so/core', () => ({ - log: vi.fn(), - isLoggerSet: vi.fn(() => true), - setLogger: vi.fn(), -})); +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(() => { From 46b2fd191568a47b0019f6956dd5e7e1b149d263 Mon Sep 17 00:00:00 2001 From: Gleb Kiva Date: Sat, 11 Apr 2026 20:24:17 +0300 Subject: [PATCH 9/9] fix(javascript): simplify global error titles and rename error composer --- packages/javascript/src/catcher.ts | 8 ++--- packages/javascript/src/utils/error.ts | 31 ++++++++++--------- .../tests/catcher.global-handlers.test.ts | 2 +- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/javascript/src/catcher.ts b/packages/javascript/src/catcher.ts index dd5be3c0..b02488bb 100644 --- a/packages/javascript/src/catcher.ts +++ b/packages/javascript/src/catcher.ts @@ -31,7 +31,7 @@ import { import { HawkLocalStorage } from './storages/hawk-local-storage'; import { createBrowserLogger } from './logger/logger'; import { BrowserRandomGenerator } from './utils/random'; -import { type CapturedError, fillCapturedError, getErrorFromErrorEvent } from './utils/error'; +import { type CapturedError, composeCapturedError, getErrorFromErrorEvent } from './utils/error'; /** * Allow to use global VERSION, that will be overwritten by Webpack @@ -231,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(fillCapturedError(message), undefined, context); + void this.formatAndSend(composeCapturedError(message), undefined, context); } /** @@ -243,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(fillCapturedError(error), addons); + void this.formatAndSend(composeCapturedError(error), addons); } /** @@ -256,7 +256,7 @@ export default class Catcher { this.vue = new VueIntegration( vue, (error: Error, addons: VueIntegrationAddons) => { - void this.formatAndSend(fillCapturedError(error), { + void this.formatAndSend(composeCapturedError(error), { vue: addons, }); }, diff --git a/packages/javascript/src/utils/error.ts b/packages/javascript/src/utils/error.ts index b9755c9b..21fe8037 100644 --- a/packages/javascript/src/utils/error.ts +++ b/packages/javascript/src/utils/error.ts @@ -22,16 +22,16 @@ export type CapturedError = { * Prefers `.message` on objects, falls back to the value itself for strings, * and serializes everything else. * - * @param safeError - Sanitized error value (any shape) + * @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(safeError: unknown): string | undefined { - if (safeError == null) { +function getTitleFromError(sanitizedError: unknown): string | undefined { + if (sanitizedError == null) { return undefined; } const message = - typeof safeError === 'object' && 'message' in safeError ? (safeError as Error).message : safeError; + typeof sanitizedError === 'object' && 'message' in sanitizedError ? (sanitizedError as Error).message : sanitizedError; if (typeof message === 'string') { return message || undefined; @@ -49,23 +49,22 @@ function getTitleFromError(safeError: unknown): string | undefined { * Extracts an error type name from an unknown sanitized error. * Returns `.name` only when it is a non-empty string (e.g. 'TypeError'). * - * @param safeError - Sanitized error value (any shape) + * @param sanitizedError - Value returned by `Sanitizer.sanitize(error)` * @returns The error name string, or undefined if absent or empty */ -function getTypeFromError(safeError: unknown): string | undefined { - const name = (safeError as Error)?.name; - - return name || undefined; +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 fillCapturedError( +export function composeCapturedError( error: unknown, fallbackValues: { title?: string; type?: string } = {} ): CapturedError { @@ -89,18 +88,22 @@ export function getErrorFromErrorEvent(event: ErrorEvent | PromiseRejectionEvent if (event.type === 'error') { event = event as ErrorEvent; - return fillCapturedError(event.error, { - title: event.message && `'${event.message}' at ${event.filename || ''}:${event.lineno}:${event.colno}`, + 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 fillCapturedError(event.reason, { + return composeCapturedError(event.reason, { type: 'UnhandledRejection', }); } - return fillCapturedError(undefined); + /* + 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 660fffcf..9f8b1240 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.' at :0:0"); + expect(getLastPayload(sendSpy).title).toBe("Script error."); }); it('should capture unhandled promise rejections', async () => {