-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathcatcher.ts
More file actions
678 lines (585 loc) · 17.6 KB
/
catcher.ts
File metadata and controls
678 lines (585 loc) · 17.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
import Socket from './modules/socket';
import Sanitizer from './modules/sanitizer';
import StackParser from './modules/stackParser';
import type { BreadcrumbsAPI, CatcherMessage, HawkInitialSettings, HawkJavaScriptEvent, Transport } from './types';
import { VueIntegration } from './integrations/vue';
import type {
AffectedUser,
DecodedIntegrationToken,
EncodedIntegrationToken,
EventContext,
JavaScriptAddons,
Json,
VueIntegrationAddons
} from '@hawk.so/types';
import type { JavaScriptCatcherIntegrations } from './types/integrations';
import { EventRejectedError } from './errors';
import { isErrorProcessed, markErrorAsProcessed } from './utils/event';
import { getErrorFromErrorEvent } from './utils/error';
import { BrowserRandomGenerator } from './utils/random';
import { ConsoleCatcher } from './addons/consoleCatcher';
import { BreadcrumbManager } from './addons/breadcrumbs';
import { isValidEventPayload, validateContext, validateUser } from './utils/validation';
import { HawkUserManager, isLoggerSet, log, setLogger } from '@hawk.so/core';
import { HawkLocalStorage } from './storages/hawk-local-storage';
import { createBrowserLogger } from './logger/logger';
/**
* Allow to use global VERSION, that will be overwritten by Webpack
*/
declare const VERSION: string;
/**
* Registers a global logger instance if not already done.
*/
if (!isLoggerSet()) {
setLogger(createBrowserLogger(VERSION));
}
/**
* Hawk JavaScript Catcher
* Module for errors and exceptions tracking
*
* @copyright CodeX
*/
export default class Catcher {
/**
* JS Catcher version
*/
public readonly version: string = VERSION;
/**
* Vue.js integration instance
*/
public vue: VueIntegration | null = null;
/**
* Catcher Type
*/
private readonly type: string = 'errors/javascript';
/**
* User project's Integration Token
*/
private readonly token: EncodedIntegrationToken;
/**
* Enable debug mode
*/
private readonly debug: boolean;
/**
* Current bundle version
*/
private readonly release: string | undefined;
/**
* Any additional data passed by user for sending with all messages
*/
private context: EventContext | undefined;
/**
* This Method allows developer to filter any data you don't want sending to Hawk.
* - Return modified event — it will be sent instead of the original.
* - Return `false` — the event will be dropped entirely.
* - Any other value is invalid — the original event is sent as-is (a warning is logged).
*/
private readonly beforeSend: undefined | ((event: HawkJavaScriptEvent) => HawkJavaScriptEvent | false | void);
/**
* Transport for dialog between Catcher and Collector
* (WebSocket decorator by default, or custom via settings.transport)
*/
private readonly transport: Transport;
/**
* Module for parsing backtrace
*/
private readonly stackParser: StackParser = new StackParser();
/**
* Disable Vue.js error handler
*/
private readonly disableVueErrorHandler: boolean = false;
/**
* Console log handler
*/
private readonly consoleTracking: boolean;
/**
* Console catcher instance
*/
private readonly consoleCatcher: ConsoleCatcher | null = null;
/**
* Breadcrumb manager instance
*/
private readonly breadcrumbManager: BreadcrumbManager | null;
/**
* Manages currently authenticated user identity.
*/
private readonly userManager: HawkUserManager = new HawkUserManager(
new HawkLocalStorage(),
new BrowserRandomGenerator()
);
/**
* Catcher constructor
*
* @param {HawkInitialSettings|string} settings - If settings is a string, it means an Integration Token
*/
constructor(settings: HawkInitialSettings | string) {
if (typeof settings === 'string') {
settings = {
token: settings,
} as HawkInitialSettings;
}
this.token = settings.token;
this.debug = settings.debug || false;
this.release = settings.release !== undefined ? String(settings.release) : undefined;
if (settings.user) {
this.setUser(settings.user);
}
this.setContext(settings.context || undefined);
this.beforeSend = settings.beforeSend;
this.disableVueErrorHandler =
settings.disableVueErrorHandler !== null && settings.disableVueErrorHandler !== undefined
? settings.disableVueErrorHandler
: false;
this.consoleTracking =
settings.consoleTracking !== null && settings.consoleTracking !== undefined
? settings.consoleTracking
: true;
if (!this.token) {
log(
'Integration Token is missed. You can get it on https://hawk.so at Project Settings.',
'warn'
);
return;
}
/**
* Init transport
*/
this.transport = settings.transport ?? new Socket({
collectorEndpoint: settings.collectorEndpoint || `wss://${this.getIntegrationId()}.k1.hawk.so:443/ws`,
reconnectionAttempts: settings.reconnectionAttempts,
reconnectionTimeout: settings.reconnectionTimeout,
onClose(): void {
log(
'Connection lost. Connection will be restored when new errors occurred',
'info'
);
},
});
if (this.consoleTracking) {
this.consoleCatcher = ConsoleCatcher.getInstance();
this.consoleCatcher.init();
}
/**
* Initialize breadcrumbs
*/
if (settings.breadcrumbs !== false) {
this.breadcrumbManager = BreadcrumbManager.getInstance();
this.breadcrumbManager.init(settings.breadcrumbs ?? {});
} else {
this.breadcrumbManager = null;
}
/**
* Set global handlers
*/
if (!settings.disableGlobalErrorsHandling) {
this.initGlobalHandlers();
}
if (settings.vue) {
this.connectVue(settings.vue);
}
}
/**
* Send test event from client
*/
public test(): void {
const fakeEvent = new Error('Hawk JavaScript Catcher test message.');
this.send(fakeEvent);
}
/**
* Public method for manual sending messages to the Hawk
* Can be called in user's try-catch blocks or by other custom logic
*
* @param message - what to send
* @param [context] - any additional data to send
*/
public send(message: Error | string, context?: EventContext): void {
void this.formatAndSend(message, undefined, context);
}
/**
* Method for Frameworks SDK using own error handlers.
* Allows to send errors to Hawk with additional Frameworks data (addons)
*
* @param error - error to send
* @param [addons] - framework-specific data, can be undefined
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public captureError(error: Error | string, addons?: JavaScriptCatcherIntegrations): void {
void this.formatAndSend(error, addons);
}
/**
* Add error handing to the passed Vue app
*
* @param vue - Vue app
*/
public connectVue(vue): void {
// eslint-disable-next-line no-new
this.vue = new VueIntegration(
vue,
(error: Error, addons: VueIntegrationAddons) => {
void this.formatAndSend(error, {
vue: addons,
});
},
{
disableVueErrorHandler: this.disableVueErrorHandler,
}
);
}
/**
* Update the current user information
*
* @param user - New user information
*/
public setUser(user: AffectedUser): void {
if (!validateUser(user)) {
return;
}
this.userManager.setUser(user);
}
/**
* Clear current user information
*/
public clearUser(): void {
this.userManager.clear();
}
/**
* Breadcrumbs API - provides convenient access to breadcrumb methods
*
* @example
* hawk.breadcrumbs.add({
* type: 'user',
* category: 'auth',
* message: 'User logged in',
* level: 'info',
* data: { userId: '123' }
* });
*/
public get breadcrumbs(): BreadcrumbsAPI {
return {
add: (breadcrumb, hint) => this.breadcrumbManager?.addBreadcrumb(breadcrumb, hint),
get: () => this.breadcrumbManager?.getBreadcrumbs() ?? [],
clear: () => this.breadcrumbManager?.clearBreadcrumbs(),
};
}
/**
* Update the context data that will be sent with all events
*
* @param context - New context data
*/
public setContext(context: EventContext | undefined): void {
if (!validateContext(context)) {
return;
}
this.context = context;
}
/**
* Init global errors handler
*/
private initGlobalHandlers(): void {
window.addEventListener('error', (event: ErrorEvent) => this.handleEvent(event));
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => this.handleEvent(event));
}
/**
* Handles the event and sends it to the server
*
* @param {ErrorEvent|PromiseRejectionEvent} event — (!) both for Error and Promise Rejection
*/
private async handleEvent(event: ErrorEvent | PromiseRejectionEvent): Promise<void> {
/**
* Add error to console logs
*/
if (this.consoleTracking) {
this.consoleCatcher!.addErrorEvent(event);
}
const error = getErrorFromErrorEvent(event);
void this.formatAndSend(error);
}
/**
* Format and send an error
*
* @param error - error to send
* @param integrationAddons - addons spoiled by Integration
* @param context - any additional data passed by user
*/
private async formatAndSend(
error: Error | string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
integrationAddons?: JavaScriptCatcherIntegrations,
context?: EventContext
): Promise<void> {
try {
const isAlreadySentError = isErrorProcessed(error);
if (isAlreadySentError) {
/**
* @todo add debug build and log this case
*/
return;
} else {
markErrorAsProcessed(error);
}
const errorFormatted = await this.prepareErrorFormatted(error, context);
/**
* If this event caught by integration (Vue or other), it can pass extra addons
*/
if (integrationAddons) {
this.appendIntegrationAddons(errorFormatted, Sanitizer.sanitize(integrationAddons));
}
this.sendErrorFormatted(errorFormatted);
} catch (e) {
if (e instanceof EventRejectedError) {
/**
* Event was rejected by user using the beforeSend method
*/
return;
}
log('Unable to send error. Seems like it is Hawk internal bug. Please, report it here: https://github.com/codex-team/hawk.javascript/issues/new', 'warn', e);
}
}
/**
* Sends formatted HawkEvent to the Collector
*
* @param errorFormatted - formatted error to send
*/
private sendErrorFormatted(errorFormatted: CatcherMessage): void {
this.transport.send(errorFormatted)
.catch((sendingError) => {
log('WebSocket sending error', 'error', sendingError);
});
}
/**
* Formats the event
*
* @param error - error to format
* @param context - any additional data passed by user
*/
private async prepareErrorFormatted(error: Error | string, context?: EventContext): Promise<CatcherMessage> {
let payload: HawkJavaScriptEvent = {
title: this.getTitle(error),
type: this.getType(error),
release: this.getRelease(),
breadcrumbs: this.getBreadcrumbsForEvent(),
context: this.getContext(context),
user: this.getUser(),
addons: this.getAddons(error),
backtrace: await this.getBacktrace(error),
catcherVersion: this.version,
};
/**
* Filter sensitive data
*/
if (typeof this.beforeSend === 'function') {
let eventPayloadClone: HawkJavaScriptEvent;
try {
eventPayloadClone = structuredClone(payload);
} catch {
/**
* structuredClone may fail on non-cloneable values (functions, DOM nodes, etc.)
* Fall back to passing the original — hook may mutate it, but at least reporting won't crash
*/
eventPayloadClone = payload;
}
const result = this.beforeSend(eventPayloadClone);
/**
* false → drop event
*/
if (result === false) {
throw new EventRejectedError('Event rejected by beforeSend method.');
}
/**
* Valid event payload → use it instead of original
*/
if (isValidEventPayload(result)) {
payload = result as HawkJavaScriptEvent;
} else {
/**
* Anything else is invalid — warn, payload stays untouched (hook only received a clone)
*/
log(
'Invalid beforeSend value. It should return event or false. Event is sent without changes.',
'warn'
);
}
}
return {
token: this.token,
catcherType: this.type,
payload,
};
}
/**
* 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
*/
private getRelease(): HawkJavaScriptEvent['release'] {
return this.release !== undefined ? String(this.release) : null;
}
/**
* Returns integration id from integration token
*/
private getIntegrationId(): string {
try {
const decodedIntegrationToken: DecodedIntegrationToken = JSON.parse(atob(this.token));
const { integrationId } = decodedIntegrationToken;
if (!integrationId || integrationId === '') {
throw new Error();
}
return integrationId;
} catch {
throw new Error('Invalid integration token.');
}
}
/**
* Collects additional information
*
* @param context - any additional data passed by user
*/
private getContext(context?: EventContext): HawkJavaScriptEvent['context'] {
const contextMerged = {};
if (this.context !== undefined) {
Object.assign(contextMerged, this.context);
}
if (context !== undefined) {
Object.assign(contextMerged, context);
}
return Sanitizer.sanitize(contextMerged);
}
/**
* Returns the current user if set, otherwise generates and persists an anonymous ID.
*/
private getUser(): AffectedUser {
return this.userManager.getUser();
}
/**
* Get breadcrumbs for event payload
*/
private getBreadcrumbsForEvent(): HawkJavaScriptEvent['breadcrumbs'] {
const breadcrumbs = this.breadcrumbManager?.getBreadcrumbs();
return breadcrumbs && breadcrumbs.length > 0 ? breadcrumbs : null;
}
/**
* Get parameters
*/
private getGetParams(): Json | null {
const searchString = window.location.search.substr(1);
if (!searchString) {
return null;
}
/**
* Create object from get-params string
*/
const pairs = searchString.split('&');
return pairs.reduce((accumulator, pair) => {
const [key, value] = pair.split('=');
accumulator[key] = value;
return accumulator;
}, {});
}
/**
* Return parsed backtrace information
*
* @param 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) {
return null;
}
try {
return await this.stackParser.parse(error as Error);
} catch (e) {
log('Can not parse stack:', 'warn', e);
return null;
}
}
/**
* Return some details
*
* @param {Error|string} error — caught error
*/
private getAddons(error: Error | string): HawkJavaScriptEvent['addons'] {
const { innerWidth, innerHeight } = window;
const userAgent = window.navigator.userAgent;
const location = window.location.href;
const getParams = this.getGetParams();
const consoleLogs = this.consoleTracking && this.consoleCatcher?.getConsoleLogStack();
const addons: JavaScriptAddons = {
window: {
innerWidth,
innerHeight,
},
userAgent,
url: location,
};
if (getParams) {
addons.get = getParams;
}
if (this.debug) {
addons.RAW_EVENT_DATA = this.getRawData(error);
}
if (consoleLogs && consoleLogs.length > 0) {
addons.consoleOutput = consoleLogs;
}
return addons;
}
/**
* Compose raw data object
*
* @param {Error|string} error — caught error
*/
private getRawData(error: Error | string): Json | undefined {
if (!(error instanceof Error)) {
return;
}
const stack = error.stack !== null && error.stack !== undefined ? error.stack : '';
return {
name: error.name,
message: error.message,
stack,
};
}
/**
* Extend addons object with addons spoiled by integration
* This method mutates original event
*
* @param errorFormatted - Hawk event prepared for sending
* @param integrationAddons - extra addons
*/
private appendIntegrationAddons(errorFormatted: CatcherMessage, integrationAddons: JavaScriptCatcherIntegrations): void {
Object.assign(errorFormatted.payload.addons, integrationAddons);
}
}