Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/javascript/src/addons/consoleCatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import type { ConsoleLogEvent } from '@hawk.so/types';
import Sanitizer from '../modules/sanitizer';
import { stringifyRejectionReason } from '../utils/event';

/**
* Maximum number of console logs to store
Expand Down Expand Up @@ -212,7 +213,7 @@ export class ConsoleCatcher {
method: 'error',
timestamp: new Date(),
type: 'UnhandledRejection',
message: event.reason?.message || String(event.reason),
message: stringifyRejectionReason(event.reason),
stack: event.reason?.stack || '',
fileLine: '',
};
Expand Down
17 changes: 2 additions & 15 deletions packages/javascript/src/catcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
import type { JavaScriptCatcherIntegrations } from './types/integrations';
import { EventRejectedError } from './errors';
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';
Expand Down Expand Up @@ -331,21 +332,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);
}
Expand Down
55 changes: 55 additions & 0 deletions packages/javascript/src/utils/error.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

describe cases when it returns string and when Error

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));
}
105 changes: 105 additions & 0 deletions packages/javascript/tests/utils/error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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';

describe('getErrorFromErrorEvent', () => {
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 = getErrorFromErrorEvent(event);

expect(result).toBe(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 = getErrorFromErrorEvent(event);

expect(result).toBe('<instance of DOMException>');
});

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 = getErrorFromErrorEvent(event);

expect(result).toBe('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 = getErrorFromErrorEvent(event);

expect(result).toBe('');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not the best UX

});
});

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 = getErrorFromErrorEvent(event);

expect(result).toBe(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 = getErrorFromErrorEvent(event);

expect(result).toBe(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 = getErrorFromErrorEvent(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 = getErrorFromErrorEvent(event);

expect(result).toBeUndefined();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not the best UX

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

method does not return null

});

it('should return null when event.reason is null', () => {
const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: null });

const result = getErrorFromErrorEvent(event);

expect(result).toBeNull();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

method does not return null

});

it('should handle circular references in object reason', () => {
const circularObj: Record<string, unknown> = { name: 'test' };
circularObj.self = circularObj;

const event = new PromiseRejectionEvent('unhandledrejection', { promise: Promise.resolve(), reason: circularObj });

const result = getErrorFromErrorEvent(event);

expect(result).toContain('Promise rejected with');
expect(result).toContain('<circular>');
});
});
});
Loading