Skip to content

Commit b2c8981

Browse files
committed
refactor(javascript): normalize browser error event extraction
1 parent c335586 commit b2c8981

5 files changed

Lines changed: 195 additions & 140 deletions

File tree

packages/javascript/src/addons/consoleCatcher.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
import type { ConsoleLogEvent } from '@hawk.so/types';
55
import Sanitizer from '../modules/sanitizer';
6-
import { stringifyRejectionReason } from '../utils/event';
6+
import { getErrorFromErrorEvent } from '../utils/error';
77

88
/**
99
* Maximum number of console logs to store
@@ -196,13 +196,15 @@ export class ConsoleCatcher {
196196
* @param event - The error event or promise rejection event to convert
197197
*/
198198
private createConsoleEventFromError(event: ErrorEvent | PromiseRejectionEvent): ConsoleLogEvent {
199+
const capturedError = getErrorFromErrorEvent(event);
200+
199201
if (event instanceof ErrorEvent) {
200202
return {
201203
method: 'error',
202204
timestamp: new Date(),
203-
type: event.error?.name || 'Error',
204-
message: event.error?.message || event.message,
205-
stack: event.error?.stack || '',
205+
type: capturedError.type || 'Error',
206+
message: capturedError.title,
207+
stack: (capturedError.rawError as Error)?.stack || '',
206208
fileLine: event.filename
207209
? `${event.filename}:${event.lineno}:${event.colno}`
208210
: '',
@@ -213,8 +215,8 @@ export class ConsoleCatcher {
213215
method: 'error',
214216
timestamp: new Date(),
215217
type: 'UnhandledRejection',
216-
message: stringifyRejectionReason(event.reason),
217-
stack: event.reason?.stack || '',
218+
message: capturedError.title,
219+
stack: (capturedError.rawError as Error)?.stack || '',
218220
fileLine: '',
219221
};
220222
}

packages/javascript/src/catcher.ts

Lines changed: 24 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ import type {
1010
EventContext,
1111
JavaScriptAddons,
1212
Json,
13-
VueIntegrationAddons
13+
VueIntegrationAddons,
1414
} from '@hawk.so/types';
1515
import type { JavaScriptCatcherIntegrations } from './types/integrations';
1616
import { EventRejectedError } from './errors';
1717
import { isErrorProcessed, markErrorAsProcessed } from './utils/event';
18-
import { getErrorFromErrorEvent } from './utils/error';
18+
import type { CapturedError } from './utils/error';
19+
import { fillCapturedError, getErrorFromErrorEvent } from './utils/error';
1920
import { BrowserRandomGenerator } from './utils/random';
2021
import { ConsoleCatcher } from './addons/consoleCatcher';
2122
import { BreadcrumbManager } from './addons/breadcrumbs';
@@ -222,7 +223,7 @@ export default class Catcher {
222223
* @param [context] - any additional data to send
223224
*/
224225
public send(message: Error | string, context?: EventContext): void {
225-
void this.formatAndSend(message, undefined, context);
226+
void this.formatAndSend(fillCapturedError(message), undefined, context);
226227
}
227228

228229
/**
@@ -234,7 +235,7 @@ export default class Catcher {
234235
*/
235236
// eslint-disable-next-line @typescript-eslint/no-explicit-any
236237
public captureError(error: Error | string, addons?: JavaScriptCatcherIntegrations): void {
237-
void this.formatAndSend(error, addons);
238+
void this.formatAndSend(fillCapturedError(error), addons);
238239
}
239240

240241
/**
@@ -247,7 +248,7 @@ export default class Catcher {
247248
this.vue = new VueIntegration(
248249
vue,
249250
(error: Error, addons: VueIntegrationAddons) => {
250-
void this.formatAndSend(error, {
251+
void this.formatAndSend(fillCapturedError(error), {
251252
vue: addons,
252253
});
253254
},
@@ -345,21 +346,21 @@ export default class Catcher {
345346
* @param context - any additional data passed by user
346347
*/
347348
private async formatAndSend(
348-
error: Error | string,
349+
error: CapturedError,
349350
// eslint-disable-next-line @typescript-eslint/no-explicit-any
350351
integrationAddons?: JavaScriptCatcherIntegrations,
351352
context?: EventContext
352353
): Promise<void> {
353354
try {
354-
const isAlreadySentError = isErrorProcessed(error);
355+
const isAlreadySentError = isErrorProcessed(error.rawError);
355356

356357
if (isAlreadySentError) {
357358
/**
358359
* @todo add debug build and log this case
359360
*/
360361
return;
361362
} else {
362-
markErrorAsProcessed(error);
363+
markErrorAsProcessed(error.rawError);
363364
}
364365

365366
const errorFormatted = await this.prepareErrorFormatted(error, context);
@@ -402,16 +403,17 @@ export default class Catcher {
402403
* @param error - error to format
403404
* @param context - any additional data passed by user
404405
*/
405-
private async prepareErrorFormatted(error: Error | string, context?: EventContext): Promise<CatcherMessage> {
406+
private async prepareErrorFormatted(error: CapturedError, context?: EventContext): Promise<CatcherMessage> {
407+
const { title, type, rawError } = error;
406408
let payload: HawkJavaScriptEvent = {
407-
title: this.getTitle(error),
408-
type: this.getType(error),
409+
title,
410+
type,
409411
release: this.getRelease(),
410412
breadcrumbs: this.getBreadcrumbsForEvent(),
411413
context: this.getContext(context),
412414
user: this.getUser(),
413-
addons: this.getAddons(error),
414-
backtrace: await this.getBacktrace(error),
415+
addons: this.getAddons(rawError),
416+
backtrace: await this.getBacktrace(rawError),
415417
catcherVersion: this.version,
416418
};
417419

@@ -463,44 +465,6 @@ export default class Catcher {
463465
};
464466
}
465467

466-
/**
467-
* Return event title
468-
*
469-
* @param error - event from which to get the title
470-
*/
471-
private getTitle(error: Error | string): string {
472-
const notAnError = !(error instanceof Error);
473-
474-
/**
475-
* Case when error is 'reason' of PromiseRejectionEvent
476-
* and reject() provided with text reason instead of Error()
477-
*/
478-
if (notAnError) {
479-
return error.toString() as string;
480-
}
481-
482-
return (error as Error).message;
483-
}
484-
485-
/**
486-
* Return event type: TypeError, ReferenceError etc
487-
*
488-
* @param error - caught error
489-
*/
490-
private getType(error: Error | string): HawkJavaScriptEvent['type'] {
491-
const notAnError = !(error instanceof Error);
492-
493-
/**
494-
* Case when error is 'reason' of PromiseRejectionEvent
495-
* and reject() provided with text reason instead of Error()
496-
*/
497-
if (notAnError) {
498-
return null;
499-
}
500-
501-
return (error as Error).name;
502-
}
503-
504468
/**
505469
* Release version
506470
*/
@@ -590,7 +554,7 @@ export default class Catcher {
590554
*
591555
* @param error - event from which to get backtrace
592556
*/
593-
private async getBacktrace(error: Error | string): Promise<HawkJavaScriptEvent['backtrace']> {
557+
private async getBacktrace(error: unknown): Promise<HawkJavaScriptEvent['backtrace']> {
594558
const notAnError = !(error instanceof Error);
595559

596560
/**
@@ -613,9 +577,9 @@ export default class Catcher {
613577
/**
614578
* Return some details
615579
*
616-
* @param {Error|string} error — caught error
580+
* @param {Error} error — caught error
617581
*/
618-
private getAddons(error: Error | string): HawkJavaScriptEvent['addons'] {
582+
private getAddons(error: unknown): HawkJavaScriptEvent['addons'] {
619583
const { innerWidth, innerHeight } = window;
620584
const userAgent = window.navigator.userAgent;
621585
const location = window.location.href;
@@ -649,9 +613,9 @@ export default class Catcher {
649613
/**
650614
* Compose raw data object
651615
*
652-
* @param {Error|string} error — caught error
616+
* @param {Error} error — caught error
653617
*/
654-
private getRawData(error: Error | string): Json | undefined {
618+
private getRawData(error: unknown): Json | undefined {
655619
if (!(error instanceof Error)) {
656620
return;
657621
}
@@ -672,7 +636,10 @@ export default class Catcher {
672636
* @param errorFormatted - Hawk event prepared for sending
673637
* @param integrationAddons - extra addons
674638
*/
675-
private appendIntegrationAddons(errorFormatted: CatcherMessage, integrationAddons: JavaScriptCatcherIntegrations): void {
639+
private appendIntegrationAddons(
640+
errorFormatted: CatcherMessage,
641+
integrationAddons: JavaScriptCatcherIntegrations
642+
): void {
676643
Object.assign(errorFormatted.payload.addons, integrationAddons);
677644
}
678645
}
Lines changed: 86 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,105 @@
11
import Sanitizer from '../modules/sanitizer';
22

33
/**
4-
* Extracts and normalizes error from ErrorEvent or PromiseRejectionEvent
4+
* Represents a captured error in a normalized form.
55
*
6-
* @param event - The error or promise rejection event
6+
* Motivation:
7+
* - `Error | string` is unclear and hard to work with.
8+
* - Fields can be filled from an event or from the error itself.
9+
*/
10+
export type CapturedError = {
11+
/** Human-readable error message used as a title in the dashboard */
12+
title: string;
13+
/** Error type (e.g. 'TypeError', 'NetworkError'), or null if unknown */
14+
type: string | null;
15+
/** The original (unsanitized) value — use for instanceof checks and backtrace parsing only */
16+
rawError: unknown;
17+
};
18+
19+
/**
20+
* Extracts a human-readable title from an unknown sanitized error.
21+
* Prefers `.message` on objects, falls back to the value itself for strings,
22+
* and serializes everything else.
23+
*
24+
* @param safeError - Sanitized error value (any shape)
25+
* @returns A non-empty string title, or undefined if the value is nullish or empty
726
*/
8-
export function getErrorFromErrorEvent(event: ErrorEvent | PromiseRejectionEvent): Error | string {
9-
/**
10-
* Promise rejection reason is recommended to be an Error, but it can be a string:
11-
* - Promise.reject(new Error('Reason message')) ——— recommended
12-
* - Promise.reject('Reason message')
13-
*/
14-
let error = (event as ErrorEvent).error || (event as PromiseRejectionEvent).reason;
15-
16-
/**
17-
* Case when error triggered in external script
18-
* We can't access event error object because of CORS
19-
* Event message will be 'Script error.'
20-
*/
21-
if (event instanceof ErrorEvent && error === undefined) {
22-
error = (event as ErrorEvent).message;
27+
function getTitleFromError(safeError: unknown): string | undefined {
28+
if (safeError == null) {
29+
return undefined;
2330
}
2431

25-
/**
26-
* Case when error rejected with an object
27-
* Using a string instead of wrapping in Error is more natural,
28-
* it doesn't fake the backtrace, also prefix added for dashboard readability
29-
*/
30-
if (error instanceof Object && !(error instanceof Error) && event instanceof PromiseRejectionEvent) {
31-
// Extra sanitize is needed to handle objects with circular references before JSON.stringify
32-
error = `Promise rejected with ${JSON.stringify(Sanitizer.sanitize(error))}`;
32+
const message =
33+
typeof safeError === 'object' && 'message' in safeError ? (safeError as Error).message : safeError;
34+
35+
if (typeof message === 'string') {
36+
return message || undefined;
3337
}
3438

35-
return Sanitizer.sanitize(error);
39+
try {
40+
return JSON.stringify(message);
41+
} catch {
42+
// If no JSON global is available, fall back to string conversion
43+
return String(message);
44+
}
45+
}
46+
47+
/**
48+
* Extracts an error type name from an unknown sanitized error.
49+
* Returns `.name` only when it is a non-empty string (e.g. 'TypeError').
50+
*
51+
* @param safeError - Sanitized error value (any shape)
52+
* @returns The error name string, or undefined if absent or empty
53+
*/
54+
function getTypeFromError(safeError: unknown): string | undefined {
55+
const name = (safeError as Error)?.name;
56+
57+
return name || undefined;
3658
}
3759

3860
/**
39-
* Converts a promise rejection reason to a string message.
61+
* Constructs a CapturedError from an unknown error value and optional fallbacks.
4062
*
41-
* String(obj) gives "[object Object]" and JSON.stringify("str")
42-
* adds unwanted quotes.
63+
* @param error - Any value thrown or rejected
64+
* @param fallbackValues - Fallback values from event if they can't be extracted from the error
65+
* @returns A normalized `CapturedError` object
66+
*/
67+
export function fillCapturedError(
68+
error: unknown,
69+
fallbackValues: { title?: string; type?: string } = {}
70+
): CapturedError {
71+
const sanitizedError = Sanitizer.sanitize(error);
72+
73+
return {
74+
title: getTitleFromError(sanitizedError) || fallbackValues.title || '<unknown error>',
75+
type: getTypeFromError(sanitizedError) || fallbackValues.type || null,
76+
rawError: error,
77+
};
78+
}
79+
80+
/**
81+
* Extracts and normalizes error from ErrorEvent or PromiseRejectionEvent.
82+
* Handles CORS-restricted errors (where event.error is undefined) by falling back to event.message.
4383
*
44-
* @param reason - The rejection reason from PromiseRejectionEvent
84+
* @param event - The error or promise rejection event
85+
* @returns A normalized CapturedError object
4586
*/
46-
export function stringifyRejectionReason(reason: unknown): string {
47-
if (reason instanceof Error) {
48-
return reason.message;
87+
export function getErrorFromErrorEvent(event: ErrorEvent | PromiseRejectionEvent): CapturedError {
88+
if (event.type === 'error') {
89+
event = event as ErrorEvent;
90+
91+
return fillCapturedError(event.error, {
92+
title: event.message && `'${event.message}' at ${event.filename || '<unknown file>'}:${event.lineno}:${event.colno}`,
93+
});
4994
}
50-
if (typeof reason === 'string') {
51-
return reason;
95+
96+
if (event.type === 'unhandledrejection') {
97+
event = event as PromiseRejectionEvent;
98+
99+
return fillCapturedError(event.reason, {
100+
type: 'UnhandledRejection',
101+
});
52102
}
53103

54-
return JSON.stringify(Sanitizer.sanitize(reason));
104+
return fillCapturedError(undefined);
55105
}

packages/javascript/tests/catcher.global-handlers.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ describe('Catcher', () => {
8080
await wait();
8181

8282
expect(sendSpy).toHaveBeenCalledOnce();
83-
expect(getLastPayload(sendSpy).title).toBe('Script error.');
83+
expect(getLastPayload(sendSpy).title).toBe("'Script error.' at <unknown file>:0:0");
8484
});
8585

8686
it('should capture unhandled promise rejections', async () => {

0 commit comments

Comments
 (0)