-
Notifications
You must be signed in to change notification settings - Fork 4
Preserve plain-object rejection reasons in global error handlers #181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 7 commits
28eb7b3
033bdfb
e2f8a61
47c6967
ad0313a
c335586
b2c8981
f450310
93c21d3
46b2fd1
8850693
1aadaf4
ae85a13
759e649
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -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); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -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); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -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, | ||
| }); | ||
| }, | ||
|
|
@@ -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); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe it should return only type, title, backtrace? const { type, title, backtrace } = getErrorDetailsFromEvent(event); |
||
|
|
||
| void this.formatAndSend(error); | ||
| } | ||
|
|
@@ -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); | ||
|
|
@@ -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, | ||
| }; | ||
|
|
||
|
|
@@ -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 | ||
| */ | ||
|
|
@@ -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']> { | ||
|
FeironoX5 marked this conversation as resolved.
Outdated
|
||
| const notAnError = !(error instanceof Error); | ||
|
|
||
| /** | ||
|
|
@@ -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; | ||
|
|
@@ -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; | ||
| } | ||
|
|
@@ -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); | ||
| } | ||
| } | ||
| 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) | ||
|
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( | ||
|
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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I know that it's no-op, but const errorEvent = event as ErrorEvent;
return {
rawError: errorEvent.error,
fallbackTitle: ...,
};Same for |
||
|
|
||
| return fillCapturedError(event.error, { | ||
| title: event.message && `'${event.message}' at ${event.filename || '<unknown file>'}:${event.lineno}:${event.colno}`, | ||
|
neSpecc marked this conversation as resolved.
Outdated
|
||
| }); | ||
| } | ||
|
|
||
| if (event.type === 'unhandledrejection') { | ||
|
FeironoX5 marked this conversation as resolved.
|
||
| event = event as PromiseRejectionEvent; | ||
|
|
||
| return fillCapturedError(event.reason, { | ||
| type: 'UnhandledRejection', | ||
| }); | ||
| } | ||
|
|
||
| return fillCapturedError(undefined); | ||
|
FeironoX5 marked this conversation as resolved.
Outdated
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't we use here
const typefrom 202 line?