-
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 all 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 | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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'; | ||||||
|
|
@@ -31,6 +31,7 @@ import { | |||||
| import { HawkLocalStorage } from './storages/hawk-local-storage'; | ||||||
| import { createBrowserLogger } from './logger/logger'; | ||||||
| import { BrowserRandomGenerator } from './utils/random'; | ||||||
| import { type ErrorSource, getErrorFromErrorEvent, getTitleFromError, getTypeFromError } from './utils/error'; | ||||||
|
|
||||||
| /** | ||||||
| * Allow to use global VERSION, that will be overwritten by Webpack | ||||||
|
|
@@ -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({ rawError: message }, undefined, context); | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
|
|
@@ -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({ rawError: error }, addons); | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
|
|
@@ -255,7 +256,7 @@ export default class Catcher { | |||||
| this.vue = new VueIntegration( | ||||||
| vue, | ||||||
| (error: Error, addons: VueIntegrationAddons) => { | ||||||
| void this.formatAndSend(error, { | ||||||
| void this.formatAndSend({ rawError: error }, { | ||||||
| vue: addons, | ||||||
| }); | ||||||
| }, | ||||||
|
|
@@ -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); | ||||||
| } | ||||||
|
|
@@ -367,21 +354,21 @@ export default class Catcher { | |||||
| * @param context - any additional data passed by user | ||||||
| */ | ||||||
| private async formatAndSend( | ||||||
| error: Error | string, | ||||||
| error: ErrorSource, | ||||||
| // 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); | ||||||
|
|
@@ -424,16 +411,22 @@ 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: ErrorSource, context?: EventContext): Promise<CatcherMessage<typeof Catcher.type>> { | ||||||
|
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.
Suggested change
|
||||||
| const { rawError, fallbackTitle, fallbackType } = error; | ||||||
| const sanitizedError = Sanitizer.sanitize(rawError); | ||||||
| const throwableError = rawError instanceof Error ? rawError : undefined; | ||||||
| const title = getTitleFromError(sanitizedError) ?? fallbackTitle ?? '<unknown error>'; | ||||||
| const type = getTypeFromError(sanitizedError) ?? fallbackType; | ||||||
|
|
||||||
| 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(throwableError), | ||||||
| backtrace: await this.getBacktrace(throwableError), | ||||||
| catcherVersion: this.version, | ||||||
| }; | ||||||
|
|
||||||
|
|
@@ -485,44 +478,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 | ||||||
| */ | ||||||
|
|
@@ -610,21 +565,15 @@ export default class Catcher { | |||||
| /** | ||||||
| * Return parsed backtrace information | ||||||
| * | ||||||
| * @param error - event from which to get backtrace | ||||||
| * @param {Error} error - event from which to get backtrace | ||||||
| */ | ||||||
| private async getBacktrace(error: Error | string): Promise<HawkJavaScriptEvent['backtrace']> { | ||||||
| const notAnError = !(error instanceof Error); | ||||||
|
|
||||||
| /** | ||||||
| * Case when error is 'reason' of PromiseRejectionEvent | ||||||
| * and reject() provided with text reason instead of Error() | ||||||
| */ | ||||||
| if (notAnError) { | ||||||
| private async getBacktrace(error?: Error): Promise<HawkJavaScriptEvent['backtrace']> { | ||||||
| if (!error) { | ||||||
| return undefined; | ||||||
| } | ||||||
|
|
||||||
| try { | ||||||
| return await this.stackParser.parse(error as Error); | ||||||
| return await this.stackParser.parse(error); | ||||||
| } catch (e) { | ||||||
| log('Can not parse stack:', 'warn', e); | ||||||
|
|
||||||
|
|
@@ -635,9 +584,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?: Error): HawkJavaScriptEvent['addons'] { | ||||||
| const { innerWidth, innerHeight } = window; | ||||||
| const userAgent = window.navigator.userAgent; | ||||||
| const location = window.location.href; | ||||||
|
|
@@ -671,10 +620,10 @@ 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 { | ||||||
| if (!(error instanceof Error)) { | ||||||
| private getRawData(error?: Error): Json | undefined { | ||||||
| if (!error) { | ||||||
| return; | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import type { HawkJavaScriptEvent } from '@/types'; | ||
|
|
||
| /** | ||
| * Represents a raw error source before title/type normalization. | ||
| * Fallback values are provided by the event itself when raw error data is missing. | ||
| */ | ||
| export type ErrorSource = { | ||
| /** The original unsanitized value — use for instanceof checks and backtrace parsing only */ | ||
| rawError: unknown; | ||
| /** Fallback human-readable title used when rawError does not provide one */ | ||
| fallbackTitle?: string; | ||
| /** Fallback error type provided by the caller */ | ||
| fallbackType?: HawkJavaScriptEvent['type']; | ||
| }; | ||
|
|
||
| /** | ||
| * Extracts a human-readable title from an unknown value. | ||
| * Prefers `.message` on objects, falls back to the value itself for strings, | ||
| * and serializes everything else. | ||
| * | ||
| * @param value - Any already-safe value prepared by the caller | ||
| * @returns The error title string, or undefined if absent or empty | ||
| */ | ||
| export function getTitleFromError(value: unknown): string | undefined { | ||
| if (value == null) { | ||
| return undefined; | ||
| } | ||
|
|
||
| let message: unknown = value; | ||
| if (typeof value === 'object' && 'message' in value) { | ||
| message = (value as {message?: unknown}).message; | ||
| } | ||
|
|
||
| if (typeof message === 'string') { | ||
| return message || undefined; | ||
| } | ||
|
|
||
| try { | ||
| return JSON.stringify(message); | ||
| } catch { | ||
| /** | ||
| * If no JSON global is available or serialization fails, | ||
| * fall back to string conversion | ||
| */ | ||
| return String(message); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Extracts an error type name from an unknown value. | ||
| * | ||
| * @param value - Any already-safe value prepared by the caller | ||
| * @returns The error name string, or undefined if absent or empty | ||
| */ | ||
| export function getTypeFromError(value: unknown): HawkJavaScriptEvent['type'] | undefined { | ||
|
Contributor
Author
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. it will be simpler |
||
| if (typeof value !== 'object' || value === null || !('name' in value)) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const name = (value as {name?: unknown}).name; | ||
|
|
||
| return typeof name === 'string' && name ? name : undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Extracts raw error data and event-level fallbacks 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 Raw error source with optional event-level fallback values | ||
| */ | ||
| export function getErrorFromErrorEvent(event: ErrorEvent | PromiseRejectionEvent): ErrorSource { | ||
| if (event.type === 'error') { | ||
| event = event as ErrorEvent; | ||
|
|
||
| return { | ||
| rawError: event.error, | ||
| fallbackTitle: event.message | ||
| ? (event.filename ? `'${event.message}' at ${event.filename}:${event.lineno}:${event.colno}` : event.message) | ||
| : undefined, | ||
| }; | ||
| } | ||
|
|
||
| if (event.type === 'unhandledrejection') { | ||
|
FeironoX5 marked this conversation as resolved.
|
||
| event = event as PromiseRejectionEvent; | ||
|
|
||
| return { | ||
| rawError: event.reason, | ||
|
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. reason is not an error |
||
| fallbackType: 'UnhandledRejection', | ||
|
Comment on lines
+78
to
+89
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. specifying of fallback title and type is a part of internal business logic of getErrorFromErrorEvent. It should not be exported |
||
| }; | ||
| } | ||
|
|
||
| return { rawError: undefined }; | ||
| } | ||
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.
maybe it should return only type, title, backtrace?