Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/sdk/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"clean": "rimraf \"lib\"",
"format": "prettier --write '**/*.ts'",
"lint": "eslint . --ext .ts",
"start": "NODE_ENV=production node ./lib/index.js",
"watch": "tsc -w"
},
"repository": {
Expand Down
18 changes: 15 additions & 3 deletions examples/sdk/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ async function sendMessage(message: string, attributes: Record<string, number>)
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<string, number>) {
if (!client.metrics) {
console.log('metrics are unavailable');
Expand All @@ -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) {
Expand All @@ -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;
}
Expand Down
15 changes: 1 addition & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions packages/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"test": "NODE_ENV=test jest"
},
"engines": {
"node": ">=11.15.54"
"node": ">=14"
},
"repository": {
"type": "git",
Expand All @@ -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",
Expand Down
1 change: 0 additions & 1 deletion packages/node/samplefile.txt

This file was deleted.

101 changes: 99 additions & 2 deletions packages/node/src/BacktraceClient.ts
Original file line number Diff line number Diff line change
@@ -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 { NodeOptionReader } from './common/NodeOptionReader';

export class BacktraceClient extends BacktraceCoreClient {
constructor(
Expand All @@ -25,9 +27,104 @@ export class BacktraceClient extends BacktraceCoreClient {
undefined,
new VariableDebugIdMapProvider(global as DebugIdContainer),
);

this.captureUnhandledErrors(options.captureUnhandledErrors, options.captureUnhandledPromiseRejections);
}

public static builder(options: BacktraceConfiguration): BacktraceClientBuilder {
return new BacktraceClientBuilder(options);
}

private captureUnhandledErrors(captureUnhandledExceptions = true, captureUnhandledRejections = true) {
if (!captureUnhandledExceptions && !captureUnhandledRejections) {
return;
}

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;
}

// 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:
* - 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;
}

const captureUnhandledRejectionsCallback = async (reason: unknown) => {
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',
}),
);

// 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;
}

// everything else will be handled by node
if (unhandledRejectionMode === 'none' || unhandledRejectionMode === 'warn') {
return;
}

// handle last status: warn-with-error-code
process.exitCode = 1;
const unhandledRejectionErrName = 'UnhandledPromiseRejectionWarning';

process.emitWarning(
(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(). ` +
`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);
};
process.prependListener('unhandledRejection', captureUnhandledRejectionsCallback);
}
}
55 changes: 55 additions & 0 deletions packages/node/src/common/NodeOptionReader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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.execArgv,
nodeOptions: string | undefined = process.env['NODE_OPTIONS'],
): string | boolean | 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 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;
}

if (commandOption) {
return readOptionValue(optionName, commandOption);
}

if (!nodeOptions) {
return undefined;
}

const nodeOption = nodeOptions.split(' ').find((n) => n.startsWith(optionName));

if (!nodeOption) {
return undefined;
}
return readOptionValue(optionName, nodeOption);
}
}
92 changes: 92 additions & 0 deletions packages/node/tests/common/nodeOptionReaderTests.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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 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';
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();
});
});
});
Loading