Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
13 changes: 8 additions & 5 deletions 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 { getErrorFromErrorEvent } from '../utils/error';

/**
* Maximum number of console logs to store
Expand Down Expand Up @@ -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}`
: '',
Expand All @@ -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: '',
};
}
Expand Down
96 changes: 25 additions & 71 deletions packages/javascript/src/catcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +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 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';
Expand Down Expand Up @@ -221,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);
}

/**
Expand All @@ -233,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);
}

/**
Expand All @@ -246,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,
});
},
Expand Down Expand Up @@ -331,21 +333,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 All @@ -358,21 +346,21 @@ 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<void> {
try {
const isAlreadySentError = isErrorProcessed(error);
const isAlreadySentError = isErrorProcessed(error.rawError);

if (isAlreadySentError) {
/**
* @todo add debug build and log this case
*/
return;
} else {
markErrorAsProcessed(error);
markErrorAsProcessed(error.rawError);
}

const errorFormatted = await this.prepareErrorFormatted(error, context);
Expand Down Expand Up @@ -415,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<CatcherMessage> {
private async prepareErrorFormatted(error: CapturedError, context?: EventContext): Promise<CatcherMessage> {
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,
};

Expand Down Expand Up @@ -476,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
*/
Expand Down Expand Up @@ -603,7 +554,7 @@ export default class Catcher {
*
* @param error - event from which to get backtrace
*/
private async getBacktrace(error: Error | string): Promise<HawkJavaScriptEvent['backtrace']> {
private async getBacktrace(error: unknown): Promise<HawkJavaScriptEvent['backtrace']> {
Comment thread
FeironoX5 marked this conversation as resolved.
Outdated
const notAnError = !(error instanceof Error);

/**
Expand All @@ -626,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;
Expand Down Expand Up @@ -662,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;
}
Expand All @@ -685,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);
}
}
105 changes: 105 additions & 0 deletions packages/javascript/src/utils/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import Sanitizer from '../modules/sanitizer';

/**
* 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 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)
Comment thread
FeironoX5 marked this conversation as resolved.
Outdated
* @returns A non-empty string title, or undefined if the value is nullish or empty
*/
function getTitleFromError(safeError: unknown): string | undefined {
if (safeError == null) {
return undefined;
}

const message =
typeof safeError === 'object' && 'message' in safeError ? (safeError as Error).message : safeError;

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 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;
}

/**
* Constructs a CapturedError from an unknown error value and optional fallbacks.
*
* @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(
Comment thread
FeironoX5 marked this conversation as resolved.
Outdated
error: unknown,
fallbackValues: { title?: string; type?: string } = {}
): CapturedError {
const sanitizedError = Sanitizer.sanitize(error);

return {
title: getTitleFromError(sanitizedError) || fallbackValues.title || '<unknown error>',
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 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 fillCapturedError(event.error, {
title: event.message && `'${event.message}' at ${event.filename || '<unknown file>'}:${event.lineno}:${event.colno}`,
Comment thread
neSpecc marked this conversation as resolved.
Outdated
});
}

if (event.type === 'unhandledrejection') {
Comment thread
FeironoX5 marked this conversation as resolved.
event = event as PromiseRejectionEvent;

return fillCapturedError(event.reason, {
type: 'UnhandledRejection',
});
}

return fillCapturedError(undefined);
Comment thread
FeironoX5 marked this conversation as resolved.
Outdated
}
2 changes: 1 addition & 1 deletion packages/javascript/tests/catcher.global-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <unknown file>:0:0");
});

it('should capture unhandled promise rejections', async () => {
Expand Down
Loading