From 2a9ef47a1949602516b6843026772d6de84e99d1 Mon Sep 17 00:00:00 2001 From: Konrad Dysput Date: Tue, 18 Jul 2023 12:40:36 +0200 Subject: [PATCH 1/8] Node unhandled exceptions/rejections --- .vscode/launch.json | 2 +- examples/sdk/node/src/consts.ts | 2 +- examples/sdk/node/src/index.ts | 18 ++++++-- package-lock.json | 15 +------ packages/node/package.json | 3 +- packages/node/samplefile.txt | 1 - packages/node/src/BacktraceClient.ts | 41 ++++++++++++++++++- .../node/src/common/StrictModeDetector.ts | 5 +++ 8 files changed, 63 insertions(+), 24 deletions(-) delete mode 100644 packages/node/samplefile.txt create mode 100644 packages/node/src/common/StrictModeDetector.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 6b3bddb5..cb757686 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,7 +2,7 @@ "version": "0.1.0", "configurations": [ { - "name": "Launch", + "name": "Launch nodejs example", "program": "${workspaceFolder}/examples/sdk/node/lib/index.js", "request": "launch", "localRoot": "${workspaceFolder}", diff --git a/examples/sdk/node/src/consts.ts b/examples/sdk/node/src/consts.ts index dd29bab4..37ff0cce 100644 --- a/examples/sdk/node/src/consts.ts +++ b/examples/sdk/node/src/consts.ts @@ -1,2 +1,2 @@ export const SUBMISSION_URL = - 'https://submit.backtrace.io/your-universe/0000000000000000000000000000000000000000000000000000000000000000/json'; + 'https://kdysput.in.backtrace.io:6098/post?format=json&token=590d39eb154cff1d30f2b689f9a928bb592b25e7e7c10192fe208485ea68d91c'; diff --git a/examples/sdk/node/src/index.ts b/examples/sdk/node/src/index.ts index 80f8ec48..01cf3888 100644 --- a/examples/sdk/node/src/index.ts +++ b/examples/sdk/node/src/index.ts @@ -38,6 +38,13 @@ async function sendMessage(message: string, attributes: Record) await client.send(message, attributes); } +async function rejectPromise(message: string) { + return new Promise(() => { + console.log('Rejecting promise without .catch and finally.'); + throw new Error(message); + }); +} + function addEvent(name: string, attributes: Record) { if (!client.metrics) { console.log('metrics are unavailable'); @@ -57,8 +64,9 @@ function showMenu() { `Please pick one of available options:\n` + `1. Send an exception\n` + `2. Send a message\n` + - `3. Add a new summed event\n` + - `4. Send all metrics\n` + + `3. Throw rejected promise\n` + + `4. Add a new summed event\n` + + `5. Send all metrics\n` + `0. Exit\n` + `Type the option number:`; reader.question(menu, async function executeUserOption(optionString: string) { @@ -76,10 +84,14 @@ function showMenu() { break; } case 3: { - addEvent('Option clicked', attributes); + rejectPromise('Rejected promise'); break; } case 4: { + addEvent('Option clicked', attributes); + break; + } + case 5: { sendMetrics(); break; } diff --git a/package-lock.json b/package-lock.json index 24d922cb..4dced60d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11536,7 +11536,6 @@ }, "devDependencies": { "@types/jest": "^29.5.1", - "@types/node": "^11.15.54", "jest": "^29.5.0", "ts-jest": "^29.1.0", "ts-loader": "^9.4.3", @@ -11546,14 +11545,9 @@ "webpack-node-externals": "^3.0.0" }, "engines": { - "node": ">=11.15.54" + "node": ">=14" } }, - "packages/node/node_modules/@types/node": { - "version": "11.15.54", - "dev": true, - "license": "MIT" - }, "packages/react": { "name": "@backtrace/react", "version": "0.0.1", @@ -12033,7 +12027,6 @@ "version": "file:packages/node", "requires": { "@types/jest": "^29.5.1", - "@types/node": "^11.15.54", "form-data": "^4.0.0", "jest": "^29.5.0", "native-reg": "^1.1.1", @@ -12043,12 +12036,6 @@ "webpack": "^5.87.0", "webpack-cli": "^5.1.4", "webpack-node-externals": "^3.0.0" - }, - "dependencies": { - "@types/node": { - "version": "11.15.54", - "dev": true - } } }, "@backtrace/react": { diff --git a/packages/node/package.json b/packages/node/package.json index cd88047f..ffca9ecc 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -13,7 +13,7 @@ "test": "NODE_ENV=test jest" }, "engines": { - "node": ">=11.15.54" + "node": ">=14" }, "repository": { "type": "git", @@ -36,7 +36,6 @@ "homepage": "https://github.com/backtrace-labs/backtrace-javascript#readme", "devDependencies": { "@types/jest": "^29.5.1", - "@types/node": "^11.15.54", "jest": "^29.5.0", "ts-jest": "^29.1.0", "ts-loader": "^9.4.3", diff --git a/packages/node/samplefile.txt b/packages/node/samplefile.txt deleted file mode 100644 index 6a537b5b..00000000 --- a/packages/node/samplefile.txt +++ /dev/null @@ -1 +0,0 @@ -1234567890 \ No newline at end of file diff --git a/packages/node/src/BacktraceClient.ts b/packages/node/src/BacktraceClient.ts index fa5a106f..04527c82 100644 --- a/packages/node/src/BacktraceClient.ts +++ b/packages/node/src/BacktraceClient.ts @@ -1,14 +1,16 @@ import { BacktraceAttributeProvider, + BacktraceConfiguration as CoreConfiguration, BacktraceCoreClient, + BacktraceReport, BacktraceRequestHandler, - BacktraceConfiguration as CoreConfiguration, DebugIdContainer, VariableDebugIdMapProvider, } from '@backtrace/sdk-core'; -import { BacktraceConfiguration } from './BacktraceConfiguration'; import { AGENT } from './agentDefinition'; +import { BacktraceConfiguration } from './BacktraceConfiguration'; import { BacktraceClientBuilder } from './builder/BacktraceClientBuilder'; +import { StrictModeDetector } from './common/StrictModeDetector'; export class BacktraceClient extends BacktraceCoreClient { constructor( @@ -25,9 +27,44 @@ export class BacktraceClient extends BacktraceCoreClient { undefined, new VariableDebugIdMapProvider(global as DebugIdContainer), ); + this.captureUnhandledErrors(); } public static builder(options: BacktraceConfiguration): BacktraceClientBuilder { return new BacktraceClientBuilder(options); } + + private captureUnhandledErrors() { + const isStrictModeEnabled = StrictModeDetector.enabled(); + + process.prependListener( + 'uncaughtExceptionMonitor', + async (error: Error, origin?: 'uncaughtException' | 'unhandledRejection') => { + // all rejected promises will be captured via different handler + if (origin === 'unhandledRejection') { + return; + } + await this.send(new BacktraceReport(error, { 'error.type': 'Unhandled exception' })); + }, + ); + + process.prependListener('unhandledRejection', async (reason) => { + const error = + reason instanceof Error + ? reason + : typeof reason === 'string' + ? new Error(reason) + : new Error('Unhandled rejection'); + await this.send( + new BacktraceReport(error, { + 'error.type': 'Unhandled exception', + }), + ); + + if (isStrictModeEnabled) { + console.error(reason); + process.exit(1); + } + }); + } } diff --git a/packages/node/src/common/StrictModeDetector.ts b/packages/node/src/common/StrictModeDetector.ts new file mode 100644 index 00000000..2e87affc --- /dev/null +++ b/packages/node/src/common/StrictModeDetector.ts @@ -0,0 +1,5 @@ +export class StrictModeDetector { + public static enabled() { + return !this; + } +} From c13a90a0e7d15e9b7e31ed0a374e317a6296fed8 Mon Sep 17 00:00:00 2001 From: Konrad Dysput Date: Tue, 18 Jul 2023 12:43:28 +0200 Subject: [PATCH 2/8] Correct submission URL --- examples/sdk/node/src/consts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/sdk/node/src/consts.ts b/examples/sdk/node/src/consts.ts index 37ff0cce..dd29bab4 100644 --- a/examples/sdk/node/src/consts.ts +++ b/examples/sdk/node/src/consts.ts @@ -1,2 +1,2 @@ export const SUBMISSION_URL = - 'https://kdysput.in.backtrace.io:6098/post?format=json&token=590d39eb154cff1d30f2b689f9a928bb592b25e7e7c10192fe208485ea68d91c'; + 'https://submit.backtrace.io/your-universe/0000000000000000000000000000000000000000000000000000000000000000/json'; From 95b6893f8b2b865aad734c5f7eb951048e14a3a7 Mon Sep 17 00:00:00 2001 From: Konrad Dysput Date: Mon, 24 Jul 2023 14:55:03 +0200 Subject: [PATCH 3/8] Unhandled exception handler --- .vscode/launch.json | 3 +- examples/sdk/node/package.json | 1 + packages/node/src/BacktraceClient.ts | 14 ++-- packages/node/src/common/NodeOptionReader.ts | 41 ++++++++++ .../node/src/common/StrictModeDetector.ts | 5 -- .../common/nodeOptionReaderTests.spec.ts | 77 +++++++++++++++++++ 6 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 packages/node/src/common/NodeOptionReader.ts delete mode 100644 packages/node/src/common/StrictModeDetector.ts create mode 100644 packages/node/tests/common/nodeOptionReaderTests.spec.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index cb757686..b342007c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,11 +2,12 @@ "version": "0.1.0", "configurations": [ { - "name": "Launch nodejs example", + "name": "Launch", "program": "${workspaceFolder}/examples/sdk/node/lib/index.js", "request": "launch", "localRoot": "${workspaceFolder}", "remoteRoot": "${workspaceFolder}", + "runtimeArgs": ["--unhandled-rejections=throw"], "skipFiles": ["/**", "${workspaceFolder}/node_modules/tslib/**/*.js"], "outFiles": ["${workspaceFolder}/examples/sdk/node/lib/**/*.js"], "sourceMaps": true, diff --git a/examples/sdk/node/package.json b/examples/sdk/node/package.json index 2eab1b7f..0edff1de 100644 --- a/examples/sdk/node/package.json +++ b/examples/sdk/node/package.json @@ -12,6 +12,7 @@ "clean": "rimraf \"lib\"", "format": "prettier --write '**/*.ts'", "lint": "eslint . --ext .ts", + "start": "NODEW_ENV=production node ./lib/index.js", "watch": "tsc -w" }, "repository": { diff --git a/packages/node/src/BacktraceClient.ts b/packages/node/src/BacktraceClient.ts index 04527c82..cf5944ec 100644 --- a/packages/node/src/BacktraceClient.ts +++ b/packages/node/src/BacktraceClient.ts @@ -10,7 +10,7 @@ import { import { AGENT } from './agentDefinition'; import { BacktraceConfiguration } from './BacktraceConfiguration'; import { BacktraceClientBuilder } from './builder/BacktraceClientBuilder'; -import { StrictModeDetector } from './common/StrictModeDetector'; +import { NodeOptionReader } from './common/NodeOptionReader'; export class BacktraceClient extends BacktraceCoreClient { constructor( @@ -35,12 +35,13 @@ export class BacktraceClient extends BacktraceCoreClient { } private captureUnhandledErrors() { - const isStrictModeEnabled = StrictModeDetector.enabled(); + const unhandledRejectionMode = NodeOptionReader.read('unhandled-rejections'); + const shouldContinueExecution = unhandledRejectionMode === 'warn' || unhandledRejectionMode === 'none'; process.prependListener( 'uncaughtExceptionMonitor', async (error: Error, origin?: 'uncaughtException' | 'unhandledRejection') => { - // all rejected promises will be captured via different handler + // all rejected promises will be captured via unhandledRejection handler if (origin === 'unhandledRejection') { return; } @@ -61,10 +62,13 @@ export class BacktraceClient extends BacktraceCoreClient { }), ); - if (isStrictModeEnabled) { + if (unhandledRejectionMode !== 'none') { console.error(reason); - process.exit(1); } + if (shouldContinueExecution) { + return; + } + process.exit(1); }); } } diff --git a/packages/node/src/common/NodeOptionReader.ts b/packages/node/src/common/NodeOptionReader.ts new file mode 100644 index 00000000..786cdb42 --- /dev/null +++ b/packages/node/src/common/NodeOptionReader.ts @@ -0,0 +1,41 @@ +export class NodeOptionReader { + /** + * Read option based on the option name. If the option doesn't start with `--` + * additional prefix will be added. + * @param optionName option name + * @returns option value + */ + public static read( + optionName: string, + argv: string[] = process.argv, + nodeOptions: string | undefined = process.env['NODE_OPTIONS'], + ): string | undefined { + /** + * exec argv overrides NODE_OPTIONS. + * for example: + * yarn start = NODEW_ENV=production node --unhandled-rejections=none ./lib/index.js + * NODE_OPTIONS='--unhandled-rejections=throw' yarn start + * + * Even if NODE_OPTIONS have unhandled rejections set to throw, the value passed in argv + * will be used. + */ + if (!optionName.startsWith('--')) { + optionName = '--' + optionName; + } + + const fullCommandOption = optionName + '='; + + const commandOption = argv.find((n) => n.startsWith(fullCommandOption)); + if (commandOption) { + return commandOption.substring(fullCommandOption.length); + } + + if (!nodeOptions) { + return undefined; + } + + const nodeOption = nodeOptions.split(' ').find((n) => n.startsWith(fullCommandOption)); + + return nodeOption?.substring(fullCommandOption.length); + } +} diff --git a/packages/node/src/common/StrictModeDetector.ts b/packages/node/src/common/StrictModeDetector.ts deleted file mode 100644 index 2e87affc..00000000 --- a/packages/node/src/common/StrictModeDetector.ts +++ /dev/null @@ -1,5 +0,0 @@ -export class StrictModeDetector { - public static enabled() { - return !this; - } -} diff --git a/packages/node/tests/common/nodeOptionReaderTests.spec.ts b/packages/node/tests/common/nodeOptionReaderTests.spec.ts new file mode 100644 index 00000000..35f98266 --- /dev/null +++ b/packages/node/tests/common/nodeOptionReaderTests.spec.ts @@ -0,0 +1,77 @@ +import { NodeOptionReader } from '../../src/common/NodeOptionReader'; +describe('Node options reader', () => { + describe('argv', () => { + it('should read --unhandled-rejections option', () => { + const option = 'unhandled-rejections'; + const expectedValue = 'none'; + const optionWithValue = `--${option}=${expectedValue}`; + + const value = NodeOptionReader.read(option, [optionWithValue]); + + expect(value).toEqual(expectedValue); + }); + + it('should read --unhandled-rejections option with option passed with --', () => { + const option = '--unhandled-rejections'; + const expectedValue = 'warn'; + const optionWithValue = `${option}=${expectedValue}`; + + const value = NodeOptionReader.read(option, [optionWithValue]); + + expect(value).toEqual(expectedValue); + }); + + it('should not read --unhandled-rejections if its not available', () => { + const value = NodeOptionReader.read('unhandled-rejections'); + + expect(value).toBeUndefined(); + }); + + it('should prefer --unhandled-rejections available in argv and ignore NODE_OPTIONS', () => { + const option = '--unhandled-rejections'; + const expectedValue = 'warn'; + const expectedOptionWithValue = `${option}=${expectedValue}`; + const invalidOptionWithValue = `${option}=none`; + + const value = NodeOptionReader.read( + 'unhandled-rejections', + [expectedOptionWithValue], + invalidOptionWithValue, + ); + + expect(value).toEqual(expectedValue); + }); + }); + + describe('NODE_OPTIONS', () => { + it('should read --unhandled-rejections option from NODE_OPTIONS', () => { + const option = 'unhandled-rejections'; + const expectedValue = 'throw'; + const optionWithValue = `--${option}=${expectedValue}`; + + const value = NodeOptionReader.read(option, [], optionWithValue); + + expect(value).toEqual(expectedValue); + }); + + it('should read --unhandled-rejections option if multiple NODE_OPTIONS are available', () => { + const option = 'unhandled-rejections'; + const expectedValue = 'throw'; + const optionWithValue = `--${option}=${expectedValue}`; + + const value = NodeOptionReader.read( + option, + [], + `--max-old-space-size=8192 ${optionWithValue} --track-heap-objects`, + ); + + expect(value).toEqual(expectedValue); + }); + + it('should not read --unhandled-rejections if its not available', () => { + const value = NodeOptionReader.read('unhandled-rejections'); + + expect(value).toBeUndefined(); + }); + }); +}); From 32b0289e55d4492a3ac5131e762eed72461cd257 Mon Sep 17 00:00:00 2001 From: Konrad Dysput Date: Mon, 24 Jul 2023 15:01:11 +0200 Subject: [PATCH 4/8] Do not exted launch settings --- .vscode/launch.json | 1 - 1 file changed, 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index b342007c..6b3bddb5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,6 @@ "request": "launch", "localRoot": "${workspaceFolder}", "remoteRoot": "${workspaceFolder}", - "runtimeArgs": ["--unhandled-rejections=throw"], "skipFiles": ["/**", "${workspaceFolder}/node_modules/tslib/**/*.js"], "outFiles": ["${workspaceFolder}/examples/sdk/node/lib/**/*.js"], "sourceMaps": true, From f2ea04e166c983a4dec16120161821ecacec9ee6 Mon Sep 17 00:00:00 2001 From: Konrad Dysput Date: Tue, 25 Jul 2023 15:27:22 +0200 Subject: [PATCH 5/8] Respect unhandled rejection mode in app --- packages/node/src/BacktraceClient.ts | 60 +++++++++++++++---- packages/node/src/common/NodeOptionReader.ts | 2 +- .../configuration/BacktraceConfiguration.ts | 6 ++ 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/packages/node/src/BacktraceClient.ts b/packages/node/src/BacktraceClient.ts index cf5944ec..a2e299ad 100644 --- a/packages/node/src/BacktraceClient.ts +++ b/packages/node/src/BacktraceClient.ts @@ -27,7 +27,9 @@ export class BacktraceClient extends BacktraceCoreClient { undefined, new VariableDebugIdMapProvider(global as DebugIdContainer), ); - this.captureUnhandledErrors(); + if (options.captureUnhandledErrors !== false) { + this.captureUnhandledErrors(); + } } public static builder(options: BacktraceConfiguration): BacktraceClientBuilder { @@ -35,20 +37,36 @@ export class BacktraceClient extends BacktraceCoreClient { } private captureUnhandledErrors() { - const unhandledRejectionMode = NodeOptionReader.read('unhandled-rejections'); - const shouldContinueExecution = unhandledRejectionMode === 'warn' || unhandledRejectionMode === 'none'; - process.prependListener( 'uncaughtExceptionMonitor', async (error: Error, origin?: 'uncaughtException' | 'unhandledRejection') => { - // all rejected promises will be captured via unhandledRejection handler - if (origin === 'unhandledRejection') { - return; - } - await this.send(new BacktraceReport(error, { 'error.type': 'Unhandled exception' })); + await this.send( + new BacktraceReport(error, { 'error.type': 'Unhandled exception', errorOrigin: origin }), + ); }, ); + // Node 15+ has changed the default unhandled promise rejection behavior. + // In node 14 - the default behavior is to warn about unhandled promise rejections. In newer version + // the default mode is throw. + const nodeMajorVersion = process.version.split('.')[0]; + const unhandledRejectionMode = NodeOptionReader.read('unhandled-rejections'); + + /** + * Node JS allows to use only uncaughtExceptionMonitor only when: + * - we're in the throw/strict error mode + * - the node version 15+ + * + * In other scenarios we need to capture unhandledRejections via other event. + */ + const ignoreUnhandledRejectionHandler = + unhandledRejectionMode === 'strict' || + unhandledRejectionMode === 'throw' || + (nodeMajorVersion !== 'v14' && !unhandledRejectionMode); + + if (ignoreUnhandledRejectionHandler) { + return; + } process.prependListener('unhandledRejection', async (reason) => { const error = reason instanceof Error @@ -62,13 +80,29 @@ export class BacktraceClient extends BacktraceCoreClient { }), ); - if (unhandledRejectionMode !== 'none') { - console.error(reason); + // if there is any other unhandled rejection handler, reproduce default node behavior + // and let other handlers to capture the event + if (process.listenerCount('unhandledRejection') !== 1) { + return; } - if (shouldContinueExecution) { + + if (unhandledRejectionMode === 'none') { return; } - process.exit(1); + + if (unhandledRejectionMode === 'warn-with-error-code') { + process.exitCode = 1; + } + process.emitWarning( + `UnhandledPromiseRejectionWarning: ${error.message} \n ${error.stack ?? ''}` + + '\n' + + `Unhandled promise rejection. This error originated either by ` + + `throwing inside of an async function without a catch block, ` + + `or by rejecting a promise which was not handled with .catch().` + + `To terminate the node process on unhandled promise ` + + 'rejection, use the CLI flag `--unhandled-rejections=strict` (see ' + + 'https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). ', + ); }); } } diff --git a/packages/node/src/common/NodeOptionReader.ts b/packages/node/src/common/NodeOptionReader.ts index 786cdb42..bb9ef277 100644 --- a/packages/node/src/common/NodeOptionReader.ts +++ b/packages/node/src/common/NodeOptionReader.ts @@ -7,7 +7,7 @@ export class NodeOptionReader { */ public static read( optionName: string, - argv: string[] = process.argv, + argv: string[] = process.execArgv, nodeOptions: string | undefined = process.env['NODE_OPTIONS'], ): string | undefined { /** diff --git a/packages/sdk-core/src/model/configuration/BacktraceConfiguration.ts b/packages/sdk-core/src/model/configuration/BacktraceConfiguration.ts index 79972ac5..eb87253a 100644 --- a/packages/sdk-core/src/model/configuration/BacktraceConfiguration.ts +++ b/packages/sdk-core/src/model/configuration/BacktraceConfiguration.ts @@ -37,6 +37,12 @@ export interface BacktraceConfiguration { */ url: string; + /** + * Determines if unhandled errors and unhandled promise rejections should be captured by the library. + * By default true. + */ + captureUnhandledErrors?: boolean; + /** * Submission token - the token is required only if the user uses direct submission URL to Backtrace. */ From 99ef75263fbd38cfa83368cfd57584768d619f37 Mon Sep 17 00:00:00 2001 From: Konrad Dysput Date: Wed, 26 Jul 2023 17:10:05 +0200 Subject: [PATCH 6/8] Improve warning messages generated on unhandled promise rejection. Added options to disable the feature --- packages/node/src/BacktraceClient.ts | 59 +++++++++++++------ packages/node/src/common/NodeOptionReader.ts | 26 ++++++-- .../common/nodeOptionReaderTests.spec.ts | 15 +++++ .../configuration/BacktraceConfiguration.ts | 8 ++- 4 files changed, 83 insertions(+), 25 deletions(-) diff --git a/packages/node/src/BacktraceClient.ts b/packages/node/src/BacktraceClient.ts index a2e299ad..3128fc5c 100644 --- a/packages/node/src/BacktraceClient.ts +++ b/packages/node/src/BacktraceClient.ts @@ -27,30 +27,44 @@ export class BacktraceClient extends BacktraceCoreClient { undefined, new VariableDebugIdMapProvider(global as DebugIdContainer), ); - if (options.captureUnhandledErrors !== false) { - this.captureUnhandledErrors(); - } + + this.captureUnhandledErrors(options.captureUnhandledErrors, options.captureUnhandledPromiseRejections); } public static builder(options: BacktraceConfiguration): BacktraceClientBuilder { return new BacktraceClientBuilder(options); } - private captureUnhandledErrors() { + private captureUnhandledErrors(captureUnhandledExceptions = true, captureUnhandledRejections = true) { + if (!captureUnhandledExceptions && !captureUnhandledRejections) { + return; + } + process.prependListener( 'uncaughtExceptionMonitor', async (error: Error, origin?: 'uncaughtException' | 'unhandledRejection') => { + if (origin === 'unhandledRejection' && !captureUnhandledRejections) { + return; + } + if (origin === 'uncaughtException' && !captureUnhandledExceptions) { + return; + } await this.send( new BacktraceReport(error, { 'error.type': 'Unhandled exception', errorOrigin: origin }), ); }, ); + if (!captureUnhandledRejections) { + return; + } + // Node 15+ has changed the default unhandled promise rejection behavior. // In node 14 - the default behavior is to warn about unhandled promise rejections. In newer version // the default mode is throw. const nodeMajorVersion = process.version.split('.')[0]; const unhandledRejectionMode = NodeOptionReader.read('unhandled-rejections'); + const traceWarnings = NodeOptionReader.read('trace-warnings'); /** * Node JS allows to use only uncaughtExceptionMonitor only when: @@ -68,12 +82,8 @@ export class BacktraceClient extends BacktraceCoreClient { return; } process.prependListener('unhandledRejection', async (reason) => { - const error = - reason instanceof Error - ? reason - : typeof reason === 'string' - ? new Error(reason) - : new Error('Unhandled rejection'); + const isErrorTypeReason = reason instanceof Error; + const error = isErrorTypeReason ? reason : new Error(reason?.toString() ?? 'Unhandled rejection'); await this.send( new BacktraceReport(error, { 'error.type': 'Unhandled exception', @@ -86,23 +96,36 @@ export class BacktraceClient extends BacktraceCoreClient { return; } - if (unhandledRejectionMode === 'none') { + // everything else will be handled by node + if (unhandledRejectionMode === 'none' || unhandledRejectionMode === 'warn') { return; } - if (unhandledRejectionMode === 'warn-with-error-code') { - process.exitCode = 1; - } + // handle last status: warn-with-error-code + process.exitCode = 1; + const unhandledRejectionErrName = 'UnhandledPromiseRejectionWarning'; + process.emitWarning( - `UnhandledPromiseRejectionWarning: ${error.message} \n ${error.stack ?? ''}` + - '\n' + - `Unhandled promise rejection. This error originated either by ` + + (isErrorTypeReason ? error.stack : reason?.toString()) ?? '', + unhandledRejectionErrName, + ); + + const warning = new Error( + `Unhandled promise rejection. This error originated either by ` + `throwing inside of an async function without a catch block, ` + - `or by rejecting a promise which was not handled with .catch().` + + `or by rejecting a promise which was not handled with .catch(). ` + `To terminate the node process on unhandled promise ` + 'rejection, use the CLI flag `--unhandled-rejections=strict` (see ' + 'https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). ', ); + Object.defineProperty(warning, 'name', { + value: 'UnhandledPromiseRejectionWarning', + enumerable: false, + writable: true, + configurable: true, + }); + warning.stack = traceWarnings && isErrorTypeReason ? error.stack ?? '' : ''; + process.emitWarning(warning); }); } } diff --git a/packages/node/src/common/NodeOptionReader.ts b/packages/node/src/common/NodeOptionReader.ts index bb9ef277..3cc289a8 100644 --- a/packages/node/src/common/NodeOptionReader.ts +++ b/packages/node/src/common/NodeOptionReader.ts @@ -9,7 +9,7 @@ export class NodeOptionReader { optionName: string, argv: string[] = process.execArgv, nodeOptions: string | undefined = process.env['NODE_OPTIONS'], - ): string | undefined { + ): string | boolean | undefined { /** * exec argv overrides NODE_OPTIONS. * for example: @@ -23,19 +23,33 @@ export class NodeOptionReader { optionName = '--' + optionName; } - const fullCommandOption = optionName + '='; + const commandOption = argv.find((n) => n.startsWith(optionName)); + + function readOptionValue(optionName: string, commandOption: string): string | boolean | undefined { + let result = commandOption.substring(optionName.length); + if (!result) { + return true; + } + if (result.startsWith('=')) { + result = result.substring(1); + } + + return result; + } - const commandOption = argv.find((n) => n.startsWith(fullCommandOption)); if (commandOption) { - return commandOption.substring(fullCommandOption.length); + return readOptionValue(optionName, commandOption); } if (!nodeOptions) { return undefined; } - const nodeOption = nodeOptions.split(' ').find((n) => n.startsWith(fullCommandOption)); + const nodeOption = nodeOptions.split(' ').find((n) => n.startsWith(optionName)); - return nodeOption?.substring(fullCommandOption.length); + if (!nodeOption) { + return undefined; + } + return readOptionValue(optionName, nodeOption); } } diff --git a/packages/node/tests/common/nodeOptionReaderTests.spec.ts b/packages/node/tests/common/nodeOptionReaderTests.spec.ts index 35f98266..dc6017a0 100644 --- a/packages/node/tests/common/nodeOptionReaderTests.spec.ts +++ b/packages/node/tests/common/nodeOptionReaderTests.spec.ts @@ -11,6 +11,21 @@ describe('Node options reader', () => { expect(value).toEqual(expectedValue); }); + it('should read boolean value option if the option is defined', () => { + const option = 'trace-warnings'; + const optionWithValue = `--${option}`; + + const value = NodeOptionReader.read(option, [optionWithValue]); + + expect(value).toBeTruthy(); + }); + + it('should read undefined if the option is not available', () => { + const value = NodeOptionReader.read('', ['']); + + expect(value).toBeUndefined(); + }); + it('should read --unhandled-rejections option with option passed with --', () => { const option = '--unhandled-rejections'; const expectedValue = 'warn'; diff --git a/packages/sdk-core/src/model/configuration/BacktraceConfiguration.ts b/packages/sdk-core/src/model/configuration/BacktraceConfiguration.ts index eb87253a..3465b4cd 100644 --- a/packages/sdk-core/src/model/configuration/BacktraceConfiguration.ts +++ b/packages/sdk-core/src/model/configuration/BacktraceConfiguration.ts @@ -38,11 +38,17 @@ export interface BacktraceConfiguration { url: string; /** - * Determines if unhandled errors and unhandled promise rejections should be captured by the library. + * Determines if unhandled should be captured by the library. * By default true. */ captureUnhandledErrors?: boolean; + /** + * Determines if unhandled promise rejections should be captured by the library. + * By default false. + */ + captureUnhandledPromiseRejections?: boolean; + /** * Submission token - the token is required only if the user uses direct submission URL to Backtrace. */ From 6c3811d98b33b83b3788808e28c4bd3772118084 Mon Sep 17 00:00:00 2001 From: Konrad Dysput Date: Thu, 27 Jul 2023 10:28:27 +0200 Subject: [PATCH 7/8] Fixed typo in node_env --- examples/sdk/node/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/sdk/node/package.json b/examples/sdk/node/package.json index 0edff1de..3c88d4e0 100644 --- a/examples/sdk/node/package.json +++ b/examples/sdk/node/package.json @@ -12,7 +12,7 @@ "clean": "rimraf \"lib\"", "format": "prettier --write '**/*.ts'", "lint": "eslint . --ext .ts", - "start": "NODEW_ENV=production node ./lib/index.js", + "start": "NODE_ENV=production node ./lib/index.js", "watch": "tsc -w" }, "repository": { From b58e9216f3590e316e85fbb092dd74b1733d6a6d Mon Sep 17 00:00:00 2001 From: Konrad Dysput Date: Thu, 27 Jul 2023 13:21:14 +0200 Subject: [PATCH 8/8] Move functions to consts --- packages/node/src/BacktraceClient.ts | 31 ++++++++++++++-------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/node/src/BacktraceClient.ts b/packages/node/src/BacktraceClient.ts index 3128fc5c..ba089d1c 100644 --- a/packages/node/src/BacktraceClient.ts +++ b/packages/node/src/BacktraceClient.ts @@ -40,20 +40,17 @@ export class BacktraceClient extends BacktraceCoreClient { return; } - process.prependListener( - 'uncaughtExceptionMonitor', - async (error: Error, origin?: 'uncaughtException' | 'unhandledRejection') => { - if (origin === 'unhandledRejection' && !captureUnhandledRejections) { - return; - } - if (origin === 'uncaughtException' && !captureUnhandledExceptions) { - return; - } - await this.send( - new BacktraceReport(error, { 'error.type': 'Unhandled exception', errorOrigin: origin }), - ); - }, - ); + const captureUncaughtException = async (error: Error, origin?: 'uncaughtException' | 'unhandledRejection') => { + if (origin === 'unhandledRejection' && !captureUnhandledRejections) { + return; + } + if (origin === 'uncaughtException' && !captureUnhandledExceptions) { + return; + } + await this.send(new BacktraceReport(error, { 'error.type': 'Unhandled exception', errorOrigin: origin })); + }; + + process.prependListener('uncaughtExceptionMonitor', captureUncaughtException); if (!captureUnhandledRejections) { return; @@ -81,7 +78,8 @@ export class BacktraceClient extends BacktraceCoreClient { if (ignoreUnhandledRejectionHandler) { return; } - process.prependListener('unhandledRejection', async (reason) => { + + const captureUnhandledRejectionsCallback = async (reason: unknown) => { const isErrorTypeReason = reason instanceof Error; const error = isErrorTypeReason ? reason : new Error(reason?.toString() ?? 'Unhandled rejection'); await this.send( @@ -126,6 +124,7 @@ export class BacktraceClient extends BacktraceCoreClient { }); warning.stack = traceWarnings && isErrorTypeReason ? error.stack ?? '' : ''; process.emitWarning(warning); - }); + }; + process.prependListener('unhandledRejection', captureUnhandledRejectionsCallback); } }