Skip to content
13 changes: 8 additions & 5 deletions packages/javascript/src/addons/consoleCatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* @file Module for intercepting console logs with stack trace capture
*/
import type { ConsoleLogEvent } from '@hawk.so/types';
import { getErrorFromErrorEvent } from '../utils/error';
import { Sanitizer } from '@hawk.so/core';

/**
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
90 changes: 20 additions & 70 deletions packages/javascript/src/catcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -31,6 +31,7 @@ import {
import { HawkLocalStorage } from './storages/hawk-local-storage';
import { createBrowserLogger } from './logger/logger';
import { BrowserRandomGenerator } from './utils/random';
import { type CapturedError, composeCapturedError, getErrorFromErrorEvent } from './utils/error';

/**
* Allow to use global VERSION, that will be overwritten by Webpack
Expand Down Expand Up @@ -230,7 +231,7 @@ export default class Catcher {
* @param [context] - any additional data to send
*/
public send(message: Error | string, context?: EventContext): void {
void this.formatAndSend(message, undefined, context);
void this.formatAndSend(composeCapturedError(message), undefined, context);
}

/**
Expand All @@ -242,7 +243,7 @@ export default class Catcher {
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public captureError(error: Error | string, addons?: JavaScriptCatcherIntegrations): void {
void this.formatAndSend(error, addons);
void this.formatAndSend(composeCapturedError(error), addons);
}

/**
Expand All @@ -255,7 +256,7 @@ export default class Catcher {
this.vue = new VueIntegration(
vue,
(error: Error, addons: VueIntegrationAddons) => {
void this.formatAndSend(error, {
void this.formatAndSend(composeCapturedError(error), {
vue: addons,
});
},
Expand Down Expand Up @@ -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);
}
Expand All @@ -367,21 +354,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 @@ -424,16 +411,17 @@ export default class Catcher {
* @param error - error to format
* @param context - any additional data passed by user
*/
private async prepareErrorFormatted(error: Error | string, context?: EventContext): Promise<CatcherMessage<typeof Catcher.type>> {
private async prepareErrorFormatted(error: CapturedError, context?: EventContext): Promise<CatcherMessage<typeof Catcher.type>> {
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 @@ -485,44 +473,6 @@ export default class Catcher {
};
}

/**
* Return event title
*
* @param error - event from which to get the title
*/
private getTitle(error: Error | string): string {
const notAnError = !(error instanceof Error);

/**
* Case when error is 'reason' of PromiseRejectionEvent
* and reject() provided with text reason instead of Error()
*/
if (notAnError) {
return error.toString() as string;
}

return (error as Error).message;
}

/**
* Return event type: TypeError, ReferenceError etc
*
* @param error - caught error
*/
private getType(error: Error | string): HawkJavaScriptEvent['type'] {
const notAnError = !(error instanceof Error);

/**
* Case when error is 'reason' of PromiseRejectionEvent
* and reject() provided with text reason instead of Error()
*/
if (notAnError) {
return undefined;
}

return (error as Error).name;
}

/**
* Release version
*/
Expand Down Expand Up @@ -612,7 +562,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']> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why unknown is better than exact union type?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

it's more honest in sense that error can have any type

const notAnError = !(error instanceof Error);

/**
Expand All @@ -635,9 +585,9 @@ export default class Catcher {
/**
* Return some details
*
* @param {Error|string} error — caught error
* @param {Error} error — caught error
*/
private getAddons(error: Error | string): HawkJavaScriptEvent['addons'] {
private getAddons(error: unknown): HawkJavaScriptEvent['addons'] {
const { innerWidth, innerHeight } = window;
const userAgent = window.navigator.userAgent;
const location = window.location.href;
Expand Down Expand Up @@ -671,9 +621,9 @@ export default class Catcher {
/**
* Compose raw data object
*
* @param {Error|string} error — caught error
* @param {Error} error — caught error
*/
private getRawData(error: Error | string): Json | undefined {
private getRawData(error: unknown): Json | undefined {
if (!(error instanceof Error)) {
return;
}
Expand Down
109 changes: 109 additions & 0 deletions packages/javascript/src/utils/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { HawkJavaScriptEvent } from '@/types';
import { Sanitizer } from '@hawk.so/core';

/**
* Represents a captured error in a normalized form.
*
* Motivation:
* - `Error | string` is unclear and hard to work with.
* - Fields can be filled from an event or from the error itself.
*/
export type CapturedError = {
/** Human-readable error message used as a title in the dashboard */
title: string;
/** Error type (e.g. 'TypeError', 'NetworkError'), or undefined if unknown */
type: HawkJavaScriptEvent['type'];
/** The original (unsanitized) value — use for instanceof checks and backtrace parsing only */
rawError: unknown;
};

/**
* Extracts a human-readable title from an unknown sanitized error.
* Prefers `.message` on objects, falls back to the value itself for strings,
* and serializes everything else.
*
* @param sanitizedError - Value returned by `Sanitizer.sanitize(error)`
* @returns A non-empty string title, or undefined if the value is nullish or empty
*/
function getTitleFromError(sanitizedError: unknown): string | undefined {
if (sanitizedError == null) {
return undefined;
}

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

if (typeof message === 'string') {
return message || undefined;
}

try {
return JSON.stringify(message);
} catch {
// If no JSON global is available, fall back to string conversion
return String(message);
}
}

/**
* Extracts an error type name from an unknown sanitized error.
* Returns `.name` only when it is a non-empty string (e.g. 'TypeError').
*
* @param sanitizedError - Value returned by `Sanitizer.sanitize(error)`
* @returns The error name string, or undefined if absent or empty
*/
function getTypeFromError(sanitizedError: unknown): string | undefined {
return (sanitizedError as Error)?.name;
}

/**
* Constructs a CapturedError from an unknown error value and optional fallbacks.
* The thrown value is first passed through `Sanitizer.sanitize(error)`.
*
* @param error - Any value thrown or rejected
* @param fallbackValues - Fallback values from event if they can't be extracted from the error
* @returns A normalized `CapturedError` object
*/
export function composeCapturedError(
error: unknown,
fallbackValues: { title?: string; type?: string } = {}
): CapturedError {
const sanitizedError = Sanitizer.sanitize(error);

return {
title: getTitleFromError(sanitizedError) || fallbackValues.title || '<unknown error>',
type: getTypeFromError(sanitizedError) || fallbackValues.type,
rawError: error,
};
}

/**
* Extracts and normalizes error from ErrorEvent or PromiseRejectionEvent.
* Handles CORS-restricted errors (where event.error is undefined) by falling back to event.message.
*
* @param event - The error or promise rejection event
* @returns A normalized CapturedError object
*/
export function getErrorFromErrorEvent(event: ErrorEvent | PromiseRejectionEvent): CapturedError {
if (event.type === 'error') {
event = event as ErrorEvent;

return composeCapturedError(event.error, {
title: event.message && (event.filename ? `'${event.message}' at ${event.filename}:${event.lineno}:${event.colno}` : event.message),
});
}

if (event.type === 'unhandledrejection') {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

to we need to lowercase event.type here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

no need, we're only subscribed to unhandledrejection events, so we can gurantee unhandledrejection type:

window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => this.handleEvent(event));

event = event as PromiseRejectionEvent;

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

/*
Fallback case: ensures function always returns CapturedError.
composeCapturedError(undefined) yields object with undefined fields.
*/
return composeCapturedError(undefined);
}
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.");
});

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