From 725954cc02efb42edfa2c195117d7dd048cf4742 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 20 Dec 2023 13:13:15 +0000 Subject: [PATCH 1/2] Revert "feat(node): Rework ANR to use worker script via an integration (#9823)" This reverts commit ce9efc72bd3cfbe7a5f24f366138ecb70e7e19be. --- .gitignore | 3 - packages/core/src/index.ts | 2 +- packages/node-experimental/src/sdk/init.ts | 8 +- .../suites/anr/basic-session.js | 29 +- .../suites/anr/basic.js | 29 +- .../suites/anr/basic.mjs | 11 +- .../suites/anr/forked.js | 29 +- .../suites/anr/legacy.js | 31 -- .../suites/anr/test-transport.js | 17 + .../node-integration-tests/suites/anr/test.ts | 79 ++-- packages/node/.eslintrc.js | 1 - packages/node/package.json | 8 +- packages/node/rollup.anr-worker.config.js | 23 -- packages/node/src/anr/debugger.ts | 55 +++ packages/node/src/anr/index.ts | 302 +++++++++++++++ packages/node/src/anr/websocket.ts | 366 ++++++++++++++++++ packages/node/src/index.ts | 3 +- packages/node/src/integrations/anr/common.ts | 34 -- packages/node/src/integrations/anr/index.ts | 155 -------- packages/node/src/integrations/anr/legacy.ts | 32 -- packages/node/src/integrations/anr/worker.ts | 215 ---------- packages/node/src/integrations/index.ts | 1 - packages/node/src/sdk.ts | 6 + packages/utils/src/anr.ts | 51 ++- rollup/bundleHelpers.js | 10 - 25 files changed, 894 insertions(+), 606 deletions(-) delete mode 100644 packages/node-integration-tests/suites/anr/legacy.js create mode 100644 packages/node-integration-tests/suites/anr/test-transport.js delete mode 100644 packages/node/rollup.anr-worker.config.js create mode 100644 packages/node/src/anr/debugger.ts create mode 100644 packages/node/src/anr/index.ts create mode 100644 packages/node/src/anr/websocket.ts delete mode 100644 packages/node/src/integrations/anr/common.ts delete mode 100644 packages/node/src/integrations/anr/index.ts delete mode 100644 packages/node/src/integrations/anr/legacy.ts delete mode 100644 packages/node/src/integrations/anr/worker.ts diff --git a/.gitignore b/.gitignore index 8d7413d26757..813a38ad89d2 100644 --- a/.gitignore +++ b/.gitignore @@ -50,9 +50,6 @@ tmp.js .eslintcache **/eslintcache/* -# node worker scripts -packages/node/src/integrations/anr/worker-script.* - # deno packages/deno/build-types packages/deno/build-test diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 08f18f38ade6..114ac4a670fe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,7 +5,7 @@ export type { ServerRuntimeClientOptions } from './server-runtime-client'; export type { RequestDataIntegrationOptions } from './integrations/requestdata'; export * from './tracing'; -export { createEventEnvelope, createSessionEnvelope } from './envelope'; +export { createEventEnvelope } from './envelope'; export { addBreadcrumb, captureCheckIn, diff --git a/packages/node-experimental/src/sdk/init.ts b/packages/node-experimental/src/sdk/init.ts index 821757a9a246..e7c6ebf72381 100644 --- a/packages/node-experimental/src/sdk/init.ts +++ b/packages/node-experimental/src/sdk/init.ts @@ -4,6 +4,7 @@ import { defaultIntegrations as defaultNodeIntegrations, defaultStackParser, getSentryRelease, + isAnrChildProcess, makeNodeTransport, } from '@sentry/node'; import type { Integration } from '@sentry/types'; @@ -112,14 +113,15 @@ function getClientOptions(options: NodeExperimentalOptions): NodeExperimentalCli const release = getRelease(options.release); + // If there is no release, or we are in an ANR child process, we disable autoSessionTracking by default const autoSessionTracking = - typeof release !== 'string' + typeof release !== 'string' || isAnrChildProcess() ? false : options.autoSessionTracking === undefined ? true : options.autoSessionTracking; - - const tracesSampleRate = getTracesSampleRate(options.tracesSampleRate); + // We enforce tracesSampleRate = 0 in ANR child processes + const tracesSampleRate = isAnrChildProcess() ? 0 : getTracesSampleRate(options.tracesSampleRate); const baseOptions = dropUndefinedKeys({ transport: makeNodeTransport, diff --git a/packages/node-integration-tests/suites/anr/basic-session.js b/packages/node-integration-tests/suites/anr/basic-session.js index 03c8c94fdadf..29cdc17e76c9 100644 --- a/packages/node-integration-tests/suites/anr/basic-session.js +++ b/packages/node-integration-tests/suites/anr/basic-session.js @@ -1,28 +1,31 @@ const crypto = require('crypto'); -const assert = require('assert'); const Sentry = require('@sentry/node'); +const { transport } = require('./test-transport.js'); + +// close both processes after 5 seconds setTimeout(() => { process.exit(); -}, 10000); +}, 5000); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', debug: true, - integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 200 })], + transport, }); -function longWork() { - for (let i = 0; i < 100; i++) { - const salt = crypto.randomBytes(128).toString('base64'); - // eslint-disable-next-line no-unused-vars - const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); - assert.ok(hash); +Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200 }).then(() => { + function longWork() { + for (let i = 0; i < 100; i++) { + const salt = crypto.randomBytes(128).toString('base64'); + // eslint-disable-next-line no-unused-vars + const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); + } } -} -setTimeout(() => { - longWork(); -}, 1000); + setTimeout(() => { + longWork(); + }, 1000); +}); diff --git a/packages/node-integration-tests/suites/anr/basic.js b/packages/node-integration-tests/suites/anr/basic.js index 5e0323e2c6c5..33c4151a19f1 100644 --- a/packages/node-integration-tests/suites/anr/basic.js +++ b/packages/node-integration-tests/suites/anr/basic.js @@ -1,29 +1,32 @@ const crypto = require('crypto'); -const assert = require('assert'); const Sentry = require('@sentry/node'); +const { transport } = require('./test-transport.js'); + +// close both processes after 5 seconds setTimeout(() => { process.exit(); -}, 10000); +}, 5000); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', debug: true, autoSessionTracking: false, - integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 200 })], + transport, }); -function longWork() { - for (let i = 0; i < 100; i++) { - const salt = crypto.randomBytes(128).toString('base64'); - // eslint-disable-next-line no-unused-vars - const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); - assert.ok(hash); +Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200 }).then(() => { + function longWork() { + for (let i = 0; i < 100; i++) { + const salt = crypto.randomBytes(128).toString('base64'); + // eslint-disable-next-line no-unused-vars + const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); + } } -} -setTimeout(() => { - longWork(); -}, 1000); + setTimeout(() => { + longWork(); + }, 1000); +}); diff --git a/packages/node-integration-tests/suites/anr/basic.mjs b/packages/node-integration-tests/suites/anr/basic.mjs index 17c8a2d460df..3d10dc556076 100644 --- a/packages/node-integration-tests/suites/anr/basic.mjs +++ b/packages/node-integration-tests/suites/anr/basic.mjs @@ -1,26 +1,29 @@ -import * as assert from 'assert'; import * as crypto from 'crypto'; import * as Sentry from '@sentry/node'; +const { transport } = await import('./test-transport.js'); + +// close both processes after 5 seconds setTimeout(() => { process.exit(); -}, 10000); +}, 5000); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', debug: true, autoSessionTracking: false, - integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 200 })], + transport, }); +await Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200 }); + function longWork() { for (let i = 0; i < 100; i++) { const salt = crypto.randomBytes(128).toString('base64'); // eslint-disable-next-line no-unused-vars const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); - assert.ok(hash); } } diff --git a/packages/node-integration-tests/suites/anr/forked.js b/packages/node-integration-tests/suites/anr/forked.js index 5e0323e2c6c5..33c4151a19f1 100644 --- a/packages/node-integration-tests/suites/anr/forked.js +++ b/packages/node-integration-tests/suites/anr/forked.js @@ -1,29 +1,32 @@ const crypto = require('crypto'); -const assert = require('assert'); const Sentry = require('@sentry/node'); +const { transport } = require('./test-transport.js'); + +// close both processes after 5 seconds setTimeout(() => { process.exit(); -}, 10000); +}, 5000); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', debug: true, autoSessionTracking: false, - integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 200 })], + transport, }); -function longWork() { - for (let i = 0; i < 100; i++) { - const salt = crypto.randomBytes(128).toString('base64'); - // eslint-disable-next-line no-unused-vars - const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); - assert.ok(hash); +Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200 }).then(() => { + function longWork() { + for (let i = 0; i < 100; i++) { + const salt = crypto.randomBytes(128).toString('base64'); + // eslint-disable-next-line no-unused-vars + const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); + } } -} -setTimeout(() => { - longWork(); -}, 1000); + setTimeout(() => { + longWork(); + }, 1000); +}); diff --git a/packages/node-integration-tests/suites/anr/legacy.js b/packages/node-integration-tests/suites/anr/legacy.js deleted file mode 100644 index 46b6e1437b10..000000000000 --- a/packages/node-integration-tests/suites/anr/legacy.js +++ /dev/null @@ -1,31 +0,0 @@ -const crypto = require('crypto'); -const assert = require('assert'); - -const Sentry = require('@sentry/node'); - -setTimeout(() => { - process.exit(); -}, 10000); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - debug: true, - autoSessionTracking: false, -}); - -// eslint-disable-next-line deprecation/deprecation -Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200 }).then(() => { - function longWork() { - for (let i = 0; i < 100; i++) { - const salt = crypto.randomBytes(128).toString('base64'); - // eslint-disable-next-line no-unused-vars - const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); - assert.ok(hash); - } - } - - setTimeout(() => { - longWork(); - }, 1000); -}); diff --git a/packages/node-integration-tests/suites/anr/test-transport.js b/packages/node-integration-tests/suites/anr/test-transport.js new file mode 100644 index 000000000000..86836cd6ab35 --- /dev/null +++ b/packages/node-integration-tests/suites/anr/test-transport.js @@ -0,0 +1,17 @@ +const { TextEncoder, TextDecoder } = require('util'); + +const { createTransport } = require('@sentry/core'); +const { parseEnvelope } = require('@sentry/utils'); + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +// A transport that just logs the envelope payloads to console for checking in tests +exports.transport = () => { + return createTransport({ recordDroppedEvent: () => {}, textEncoder }, async request => { + const env = parseEnvelope(request.body, textEncoder, textDecoder); + // eslint-disable-next-line no-console + console.log(JSON.stringify(env[1][0][1])); + return { statusCode: 200 }; + }); +}; diff --git a/packages/node-integration-tests/suites/anr/test.ts b/packages/node-integration-tests/suites/anr/test.ts index a070f611a0ab..0c815c280f00 100644 --- a/packages/node-integration-tests/suites/anr/test.ts +++ b/packages/node-integration-tests/suites/anr/test.ts @@ -2,16 +2,17 @@ import * as childProcess from 'child_process'; import * as path from 'path'; import type { Event } from '@sentry/node'; import type { SerializedSession } from '@sentry/types'; -import { conditionalTest } from '../../utils'; +import { parseSemver } from '@sentry/utils'; + +const NODE_VERSION = parseSemver(process.versions.node).major || 0; /** The output will contain logging so we need to find the line that parses as JSON */ function parseJsonLines(input: string, expected: number): T { const results = input .split('\n') .map(line => { - const trimmed = line.startsWith('[ANR Worker] ') ? line.slice(13) : line; try { - return JSON.parse(trimmed) as T; + return JSON.parse(line) as T; } catch { return undefined; } @@ -23,9 +24,12 @@ function parseJsonLines(input: string, expected: number): T return results; } -conditionalTest({ min: 16 })('should report ANR when event loop blocked', () => { +describe('should report ANR when event loop blocked', () => { test('CJS', done => { - expect.assertions(13); + // The stack trace is different when node < 12 + const testFramesDetails = NODE_VERSION >= 12; + + expect.assertions(testFramesDetails ? 7 : 5); const testScriptPath = path.resolve(__dirname, 'basic.js'); @@ -37,46 +41,21 @@ conditionalTest({ min: 16 })('should report ANR when event loop blocked', () => expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms'); expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4); - expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); - expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); - - expect(event.contexts?.trace?.trace_id).toBeDefined(); - expect(event.contexts?.trace?.span_id).toBeDefined(); - - expect(event.contexts?.device?.arch).toBeDefined(); - expect(event.contexts?.app?.app_start_time).toBeDefined(); - expect(event.contexts?.os?.name).toBeDefined(); - expect(event.contexts?.culture?.timezone).toBeDefined(); + if (testFramesDetails) { + expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); + expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); + } done(); }); }); - test('Legacy API', done => { - // TODO (v8): Remove this old API and this test - expect.assertions(9); - - const testScriptPath = path.resolve(__dirname, 'legacy.js'); - - childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { - const [event] = parseJsonLines<[Event]>(stdout, 1); - - expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' }); - expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding'); - expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms'); - expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4); - - expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); - expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); - - expect(event.contexts?.trace?.trace_id).toBeDefined(); - expect(event.contexts?.trace?.span_id).toBeDefined(); - + test('ESM', done => { + if (NODE_VERSION < 14) { done(); - }); - }); + return; + } - test('ESM', done => { expect.assertions(7); const testScriptPath = path.resolve(__dirname, 'basic.mjs'); @@ -87,7 +66,7 @@ conditionalTest({ min: 16 })('should report ANR when event loop blocked', () => expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' }); expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding'); expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms'); - expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThanOrEqual(4); + expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4); expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); @@ -96,7 +75,10 @@ conditionalTest({ min: 16 })('should report ANR when event loop blocked', () => }); test('With session', done => { - expect.assertions(9); + // The stack trace is different when node < 12 + const testFramesDetails = NODE_VERSION >= 12; + + expect.assertions(testFramesDetails ? 9 : 7); const testScriptPath = path.resolve(__dirname, 'basic-session.js'); @@ -108,8 +90,10 @@ conditionalTest({ min: 16 })('should report ANR when event loop blocked', () => expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms'); expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4); - expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); - expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); + if (testFramesDetails) { + expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); + expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); + } expect(session.status).toEqual('abnormal'); expect(session.abnormal_mechanism).toEqual('anr_foreground'); @@ -119,7 +103,10 @@ conditionalTest({ min: 16 })('should report ANR when event loop blocked', () => }); test('from forked process', done => { - expect.assertions(7); + // The stack trace is different when node < 12 + const testFramesDetails = NODE_VERSION >= 12; + + expect.assertions(testFramesDetails ? 7 : 5); const testScriptPath = path.resolve(__dirname, 'forker.js'); @@ -131,8 +118,10 @@ conditionalTest({ min: 16 })('should report ANR when event loop blocked', () => expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms'); expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4); - expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); - expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); + if (testFramesDetails) { + expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); + expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); + } done(); }); diff --git a/packages/node/.eslintrc.js b/packages/node/.eslintrc.js index 6b31883cc1bc..bec6469d0e28 100644 --- a/packages/node/.eslintrc.js +++ b/packages/node/.eslintrc.js @@ -2,7 +2,6 @@ module.exports = { env: { node: true, }, - ignorePatterns: ['src/integrations/anr/worker-script.ts'], extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-optional-chaining': 'off', diff --git a/packages/node/package.json b/packages/node/package.json index a122c6f57fce..08cc8f08aafa 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -41,8 +41,8 @@ "scripts": { "build": "run-p build:transpile build:types", "build:dev": "yarn build", - "build:transpile": "yarn build:anr-worker && rollup -c rollup.npm.config.js", - "build:types": "run-s build:anr-worker build:types:core build:types:downlevel", + "build:transpile": "rollup -c rollup.npm.config.js", + "build:types": "run-s build:types:core build:types:downlevel", "build:types:core": "tsc -p tsconfig.types.json", "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", @@ -50,13 +50,11 @@ "build:transpile:watch": "rollup -c rollup.npm.config.js --watch", "build:types:watch": "tsc -p tsconfig.types.json --watch", "build:tarball": "ts-node ../../scripts/prepack.ts && npm pack ./build", - "build:anr-worker": "rollup -c rollup.anr-worker.config.js", - "build:anr-worker-stub": "echo 'export const base64WorkerScript=\"\";' > src/integrations/anr/worker-script.ts", "circularDepCheck": "madge --circular src/index.ts", "clean": "rimraf build coverage sentry-node-*.tgz", "fix": "eslint . --format stylish --fix", "lint": "eslint . --format stylish", - "test": "run-s build:anr-worker-stub test:jest test:express test:webpack test:release-health", + "test": "run-s test:jest test:express test:webpack test:release-health", "test:express": "node test/manual/express-scope-separation/start.js", "test:jest": "jest", "test:release-health": "node test/manual/release-health/runner.js", diff --git a/packages/node/rollup.anr-worker.config.js b/packages/node/rollup.anr-worker.config.js deleted file mode 100644 index acac8ea1a34f..000000000000 --- a/packages/node/rollup.anr-worker.config.js +++ /dev/null @@ -1,23 +0,0 @@ -import { makeBaseBundleConfig } from '../../rollup/index.js'; - -export default makeBaseBundleConfig({ - bundleType: 'node-worker', - entrypoints: ['src/integrations/anr/worker.ts'], - jsVersion: 'es6', - licenseTitle: '@sentry/node', - outputFileBase: () => 'worker-script.ts', - packageSpecificConfig: { - output: { - dir: './src/integrations/anr', - sourcemap: false, - }, - plugins: [ - { - name: 'output-base64-worker-script', - renderChunk(code) { - return `export const base64WorkerScript = '${Buffer.from(code).toString('base64')}';`; - }, - }, - ], - }, -}); diff --git a/packages/node/src/anr/debugger.ts b/packages/node/src/anr/debugger.ts new file mode 100644 index 000000000000..01b2a90fe0f9 --- /dev/null +++ b/packages/node/src/anr/debugger.ts @@ -0,0 +1,55 @@ +import type { StackFrame } from '@sentry/types'; +import { createDebugPauseMessageHandler } from '@sentry/utils'; +import type { Debugger } from 'inspector'; + +import { getModuleFromFilename } from '../module'; +import { createWebSocketClient } from './websocket'; + +// The only messages we care about +type DebugMessage = + | { + method: 'Debugger.scriptParsed'; + params: Debugger.ScriptParsedEventDataType; + } + | { method: 'Debugger.paused'; params: Debugger.PausedEventDataType }; + +/** + * Wraps a websocket connection with the basic logic of the Node debugger protocol. + * @param url The URL to connect to + * @param onMessage A callback that will be called with each return message from the debugger + * @returns A function that can be used to send commands to the debugger + */ +async function webSocketDebugger( + url: string, + onMessage: (message: DebugMessage) => void, +): Promise<(method: string) => void> { + let id = 0; + const webSocket = await createWebSocketClient(url); + + webSocket.on('message', (data: Buffer) => { + const message = JSON.parse(data.toString()) as DebugMessage; + onMessage(message); + }); + + return (method: string) => { + webSocket.send(JSON.stringify({ id: id++, method })); + }; +} + +/** + * Captures stack traces from the Node debugger. + * @param url The URL to connect to + * @param callback A callback that will be called with the stack frames + * @returns A function that triggers the debugger to pause and capture a stack trace + */ +export async function captureStackTrace(url: string, callback: (frames: StackFrame[]) => void): Promise<() => void> { + const sendCommand: (method: string) => void = await webSocketDebugger( + url, + createDebugPauseMessageHandler(cmd => sendCommand(cmd), getModuleFromFilename, callback), + ); + + return () => { + sendCommand('Debugger.enable'); + sendCommand('Debugger.pause'); + }; +} diff --git a/packages/node/src/anr/index.ts b/packages/node/src/anr/index.ts new file mode 100644 index 000000000000..32117f21372b --- /dev/null +++ b/packages/node/src/anr/index.ts @@ -0,0 +1,302 @@ +import { spawn } from 'child_process'; +import { getClient, getCurrentScope, makeSession, updateSession } from '@sentry/core'; +import type { Event, Session, StackFrame } from '@sentry/types'; +import { logger, watchdogTimer } from '@sentry/utils'; + +import { addEventProcessor, captureEvent, flush } from '..'; +import { captureStackTrace } from './debugger'; + +const DEFAULT_INTERVAL = 50; +const DEFAULT_HANG_THRESHOLD = 5000; + +interface Options { + /** + * The app entry script. This is used to run the same script as the child process. + * + * Defaults to `process.argv[1]`. + */ + entryScript: string; + /** + * Interval to send heartbeat messages to the child process. + * + * Defaults to 50ms. + */ + pollInterval: number; + /** + * Threshold in milliseconds to trigger an ANR event. + * + * Defaults to 5000ms. + */ + anrThreshold: number; + /** + * Whether to capture a stack trace when the ANR event is triggered. + * + * Defaults to `false`. + * + * This uses the node debugger which enables the inspector API and opens the required ports. + */ + captureStackTrace: boolean; + /** + * @deprecated Use 'init' debug option instead + */ + debug: boolean; +} + +function createAnrEvent(blockedMs: number, frames?: StackFrame[]): Event { + return { + level: 'error', + exception: { + values: [ + { + type: 'ApplicationNotResponding', + value: `Application Not Responding for at least ${blockedMs} ms`, + stacktrace: { frames }, + mechanism: { + // This ensures the UI doesn't say 'Crashed in' for the stack trace + type: 'ANR', + }, + }, + ], + }, + }; +} + +interface InspectorApi { + open: (port: number) => void; + url: () => string | undefined; +} + +/** + * Starts the node debugger and returns the inspector url. + * + * When inspector.url() returns undefined, it means the port is already in use so we try the next port. + */ +function startInspector(startPort: number = 9229): string | undefined { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const inspector: InspectorApi = require('inspector'); + let inspectorUrl: string | undefined = undefined; + let port = startPort; + + while (inspectorUrl === undefined && port < startPort + 100) { + inspector.open(port); + inspectorUrl = inspector.url(); + port++; + } + + return inspectorUrl; +} + +function startChildProcess(options: Options): void { + function log(message: string, ...args: unknown[]): void { + logger.log(`[ANR] ${message}`, ...args); + } + + try { + const env = { ...process.env }; + env.SENTRY_ANR_CHILD_PROCESS = 'true'; + + if (options.captureStackTrace) { + env.SENTRY_INSPECT_URL = startInspector(); + } + + log(`Spawning child process with execPath:'${process.execPath}' and entryScript:'${options.entryScript}'`); + + const child = spawn(process.execPath, [options.entryScript], { + env, + stdio: logger.isEnabled() ? ['inherit', 'inherit', 'inherit', 'ipc'] : ['ignore', 'ignore', 'ignore', 'ipc'], + }); + // The child process should not keep the main process alive + child.unref(); + + const timer = setInterval(() => { + try { + const currentSession = getCurrentScope()?.getSession(); + // We need to copy the session object and remove the toJSON method so it can be sent to the child process + // serialized without making it a SerializedSession + const session = currentSession ? { ...currentSession, toJSON: undefined } : undefined; + // message the child process to tell it the main event loop is still running + child.send({ session }); + } catch (_) { + // + } + }, options.pollInterval); + + child.on('message', (msg: string) => { + if (msg === 'session-ended') { + log('ANR event sent from child process. Clearing session in this process.'); + getCurrentScope()?.setSession(undefined); + } + }); + + const end = (type: string): ((...args: unknown[]) => void) => { + return (...args): void => { + clearInterval(timer); + log(`Child process ${type}`, ...args); + }; + }; + + child.on('error', end('error')); + child.on('disconnect', end('disconnect')); + child.on('exit', end('exit')); + } catch (e) { + log('Failed to start child process', e); + } +} + +function createHrTimer(): { getTimeMs: () => number; reset: () => void } { + let lastPoll = process.hrtime(); + + return { + getTimeMs: (): number => { + const [seconds, nanoSeconds] = process.hrtime(lastPoll); + return Math.floor(seconds * 1e3 + nanoSeconds / 1e6); + }, + reset: (): void => { + lastPoll = process.hrtime(); + }, + }; +} + +function handleChildProcess(options: Options): void { + process.title = 'sentry-anr'; + + function log(message: string): void { + logger.log(`[ANR child process] ${message}`); + } + + log('Started'); + let session: Session | undefined; + + function sendAnrEvent(frames?: StackFrame[]): void { + if (session) { + log('Sending abnormal session'); + updateSession(session, { status: 'abnormal', abnormal_mechanism: 'anr_foreground' }); + getClient()?.sendSession(session); + + try { + // Notify the main process that the session has ended so the session can be cleared from the scope + process.send?.('session-ended'); + } catch (_) { + // ignore + } + } + + captureEvent(createAnrEvent(options.anrThreshold, frames)); + + void flush(3000).then(() => { + // We only capture one event to avoid spamming users with errors + process.exit(); + }); + } + + addEventProcessor(event => { + // Strip sdkProcessingMetadata from all child process events to remove trace info + delete event.sdkProcessingMetadata; + event.tags = { + ...event.tags, + 'process.name': 'ANR', + }; + return event; + }); + + let debuggerPause: Promise<() => void> | undefined; + + // if attachStackTrace is enabled, we'll have a debugger url to connect to + if (process.env.SENTRY_INSPECT_URL) { + log('Connecting to debugger'); + + debuggerPause = captureStackTrace(process.env.SENTRY_INSPECT_URL, frames => { + log('Capturing event with stack frames'); + sendAnrEvent(frames); + }); + } + + async function watchdogTimeout(): Promise { + log('Watchdog timeout'); + + try { + const pauseAndCapture = await debuggerPause; + + if (pauseAndCapture) { + log('Pausing debugger to capture stack trace'); + pauseAndCapture(); + return; + } + } catch (_) { + // ignore + } + + log('Capturing event'); + sendAnrEvent(); + } + + const { poll } = watchdogTimer(createHrTimer, options.pollInterval, options.anrThreshold, watchdogTimeout); + + process.on('message', (msg: { session: Session | undefined }) => { + if (msg.session) { + session = makeSession(msg.session); + } + poll(); + }); + process.on('disconnect', () => { + // Parent process has exited. + process.exit(); + }); +} + +/** + * Returns true if the current process is an ANR child process. + */ +export function isAnrChildProcess(): boolean { + return !!process.send && !!process.env.SENTRY_ANR_CHILD_PROCESS; +} + +/** + * **Note** This feature is still in beta so there may be breaking changes in future releases. + * + * Starts a child process that detects Application Not Responding (ANR) errors. + * + * It's important to await on the returned promise before your app code to ensure this code does not run in the ANR + * child process. + * + * ```js + * import { init, enableAnrDetection } from '@sentry/node'; + * + * init({ dsn: "__DSN__" }); + * + * // with ESM + Node 14+ + * await enableAnrDetection({ captureStackTrace: true }); + * runApp(); + * + * // with CJS or Node 10+ + * enableAnrDetection({ captureStackTrace: true }).then(() => { + * runApp(); + * }); + * ``` + */ +export function enableAnrDetection(options: Partial): Promise { + // When pm2 runs the script in cluster mode, process.argv[1] is the pm2 script and process.env.pm_exec_path is the + // path to the entry script + const entryScript = options.entryScript || process.env.pm_exec_path || process.argv[1]; + + const anrOptions: Options = { + entryScript, + pollInterval: options.pollInterval || DEFAULT_INTERVAL, + anrThreshold: options.anrThreshold || DEFAULT_HANG_THRESHOLD, + captureStackTrace: !!options.captureStackTrace, + // eslint-disable-next-line deprecation/deprecation + debug: !!options.debug, + }; + + if (isAnrChildProcess()) { + handleChildProcess(anrOptions); + // In the child process, the promise never resolves which stops the app code from running + return new Promise(() => { + // Never resolve + }); + } else { + startChildProcess(anrOptions); + // In the main process, the promise resolves immediately + return Promise.resolve(); + } +} diff --git a/packages/node/src/anr/websocket.ts b/packages/node/src/anr/websocket.ts new file mode 100644 index 000000000000..7229f0fc07e7 --- /dev/null +++ b/packages/node/src/anr/websocket.ts @@ -0,0 +1,366 @@ +/* eslint-disable no-bitwise */ +/** + * A simple WebSocket client implementation copied from Rome before being modified for our use: + * https://github.com/jeremyBanks/rome/tree/b034dd22d5f024f87c50eef2872e22b3ad48973a/packages/%40romejs/codec-websocket + * + * Original license: + * + * MIT License + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { EventEmitter } from 'events'; +import * as http from 'http'; +import type { Socket } from 'net'; +import * as url from 'url'; + +type BuildFrameOpts = { + opcode: number; + fin: boolean; + data: Buffer; +}; + +type Frame = { + fin: boolean; + opcode: number; + mask: undefined | Buffer; + payload: Buffer; + payloadLength: number; +}; + +const OPCODES = { + CONTINUATION: 0, + TEXT: 1, + BINARY: 2, + TERMINATE: 8, + PING: 9, + PONG: 10, +}; + +const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +function isCompleteFrame(frame: Frame): boolean { + return Buffer.byteLength(frame.payload) >= frame.payloadLength; +} + +function unmaskPayload(payload: Buffer, mask: undefined | Buffer, offset: number): Buffer { + if (mask === undefined) { + return payload; + } + + for (let i = 0; i < payload.length; i++) { + payload[i] ^= mask[(offset + i) & 3]; + } + + return payload; +} + +function buildFrame(opts: BuildFrameOpts): Buffer { + const { opcode, fin, data } = opts; + + let offset = 6; + let dataLength = data.length; + + if (dataLength >= 65_536) { + offset += 8; + dataLength = 127; + } else if (dataLength > 125) { + offset += 2; + dataLength = 126; + } + + const head = Buffer.allocUnsafe(offset); + + head[0] = fin ? opcode | 128 : opcode; + head[1] = dataLength; + + if (dataLength === 126) { + head.writeUInt16BE(data.length, 2); + } else if (dataLength === 127) { + head.writeUInt32BE(0, 2); + head.writeUInt32BE(data.length, 6); + } + + const mask = crypto.randomBytes(4); + head[1] |= 128; + head[offset - 4] = mask[0]; + head[offset - 3] = mask[1]; + head[offset - 2] = mask[2]; + head[offset - 1] = mask[3]; + + const masked = Buffer.alloc(dataLength); + for (let i = 0; i < dataLength; ++i) { + masked[i] = data[i] ^ mask[i & 3]; + } + + return Buffer.concat([head, masked]); +} + +function parseFrame(buffer: Buffer): Frame { + const firstByte = buffer.readUInt8(0); + const isFinalFrame: boolean = Boolean((firstByte >>> 7) & 1); + const opcode: number = firstByte & 15; + + const secondByte: number = buffer.readUInt8(1); + const isMasked: boolean = Boolean((secondByte >>> 7) & 1); + + // Keep track of our current position as we advance through the buffer + let currentOffset = 2; + let payloadLength = secondByte & 127; + if (payloadLength > 125) { + if (payloadLength === 126) { + payloadLength = buffer.readUInt16BE(currentOffset); + currentOffset += 2; + } else if (payloadLength === 127) { + const leftPart = buffer.readUInt32BE(currentOffset); + currentOffset += 4; + + // The maximum safe integer in JavaScript is 2^53 - 1. An error is returned + + // if payload length is greater than this number. + if (leftPart >= Number.MAX_SAFE_INTEGER) { + throw new Error('Unsupported WebSocket frame: payload length > 2^53 - 1'); + } + + const rightPart = buffer.readUInt32BE(currentOffset); + currentOffset += 4; + + payloadLength = leftPart * Math.pow(2, 32) + rightPart; + } else { + throw new Error('Unknown payload length'); + } + } + + // Get the masking key if one exists + let mask; + if (isMasked) { + mask = buffer.slice(currentOffset, currentOffset + 4); + currentOffset += 4; + } + + const payload = unmaskPayload(buffer.slice(currentOffset), mask, 0); + + return { + fin: isFinalFrame, + opcode, + mask, + payload, + payloadLength, + }; +} + +function createKey(key: string): string { + return crypto.createHash('sha1').update(`${key}${GUID}`).digest('base64'); +} + +class WebSocketInterface extends EventEmitter { + private _alive: boolean; + private _incompleteFrame: undefined | Frame; + private _unfinishedFrame: undefined | Frame; + private _socket: Socket; + + public constructor(socket: Socket) { + super(); + // When a frame is set here then any additional continuation frames payloads will be appended + this._unfinishedFrame = undefined; + + // When a frame is set here, all additional chunks will be appended until we reach the correct payloadLength + this._incompleteFrame = undefined; + + this._socket = socket; + this._alive = true; + + socket.on('data', buff => { + this._addBuffer(buff); + }); + + socket.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'ECONNRESET') { + this.emit('close'); + } else { + this.emit('error'); + } + }); + + socket.on('close', () => { + this.end(); + }); + } + + public end(): void { + if (!this._alive) { + return; + } + + this._alive = false; + this.emit('close'); + this._socket.end(); + } + + public send(buff: string): void { + this._sendFrame({ + opcode: OPCODES.TEXT, + fin: true, + data: Buffer.from(buff), + }); + } + + private _sendFrame(frameOpts: BuildFrameOpts): void { + this._socket.write(buildFrame(frameOpts)); + } + + private _completeFrame(frame: Frame): void { + // If we have an unfinished frame then only allow continuations + const { _unfinishedFrame: unfinishedFrame } = this; + if (unfinishedFrame !== undefined) { + if (frame.opcode === OPCODES.CONTINUATION) { + unfinishedFrame.payload = Buffer.concat([ + unfinishedFrame.payload, + unmaskPayload(frame.payload, unfinishedFrame.mask, unfinishedFrame.payload.length), + ]); + + if (frame.fin) { + this._unfinishedFrame = undefined; + this._completeFrame(unfinishedFrame); + } + return; + } else { + // Silently ignore the previous frame... + this._unfinishedFrame = undefined; + } + } + + if (frame.fin) { + if (frame.opcode === OPCODES.PING) { + this._sendFrame({ + opcode: OPCODES.PONG, + fin: true, + data: frame.payload, + }); + } else { + // Trim off any excess payload + let excess; + if (frame.payload.length > frame.payloadLength) { + excess = frame.payload.slice(frame.payloadLength); + frame.payload = frame.payload.slice(0, frame.payloadLength); + } + + this.emit('message', frame.payload); + + if (excess !== undefined) { + this._addBuffer(excess); + } + } + } else { + this._unfinishedFrame = frame; + } + } + + private _addBufferToIncompleteFrame(incompleteFrame: Frame, buff: Buffer): void { + incompleteFrame.payload = Buffer.concat([ + incompleteFrame.payload, + unmaskPayload(buff, incompleteFrame.mask, incompleteFrame.payload.length), + ]); + + if (isCompleteFrame(incompleteFrame)) { + this._incompleteFrame = undefined; + this._completeFrame(incompleteFrame); + } + } + + private _addBuffer(buff: Buffer): void { + // Check if we're still waiting for the rest of a payload + const { _incompleteFrame: incompleteFrame } = this; + if (incompleteFrame !== undefined) { + this._addBufferToIncompleteFrame(incompleteFrame, buff); + return; + } + + // There needs to be atleast two values in the buffer for us to parse + // a frame from it. + // See: https://github.com/getsentry/sentry-javascript/issues/9307 + if (buff.length <= 1) { + return; + } + + const frame = parseFrame(buff); + + if (isCompleteFrame(frame)) { + // Frame has been completed! + this._completeFrame(frame); + } else { + this._incompleteFrame = frame; + } + } +} + +/** + * Creates a WebSocket client + */ +export async function createWebSocketClient(rawUrl: string): Promise { + const parts = url.parse(rawUrl); + + return new Promise((resolve, reject) => { + const key = crypto.randomBytes(16).toString('base64'); + const digest = createKey(key); + + const req = http.request({ + hostname: parts.hostname, + port: parts.port, + path: parts.path, + method: 'GET', + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': key, + 'Sec-WebSocket-Version': '13', + }, + }); + + req.on('response', (res: http.IncomingMessage) => { + if (res.statusCode && res.statusCode >= 400) { + process.stderr.write(`Unexpected HTTP code: ${res.statusCode}\n`); + res.pipe(process.stderr); + } else { + res.pipe(process.stderr); + } + }); + + req.on('upgrade', (res: http.IncomingMessage, socket: Socket) => { + if (res.headers['sec-websocket-accept'] !== digest) { + socket.end(); + reject(new Error(`Digest mismatch ${digest} !== ${res.headers['sec-websocket-accept']}`)); + return; + } + + const client = new WebSocketInterface(socket); + resolve(client); + }); + + req.on('error', err => { + reject(err); + }); + + req.end(); + }); +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index af73f34df7bc..06524bcd0c0a 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -79,8 +79,7 @@ export { defaultIntegrations, init, defaultStackParser, getSentryRelease } from export { addRequestDataToEvent, DEFAULT_USER_INCLUDES, extractRequestData } from '@sentry/utils'; export { deepReadDirSync } from './utils'; export { getModuleFromFilename } from './module'; -// eslint-disable-next-line deprecation/deprecation -export { enableAnrDetection } from './integrations/anr/legacy'; +export { enableAnrDetection, isAnrChildProcess } from './anr'; import { Integrations as CoreIntegrations } from '@sentry/core'; diff --git a/packages/node/src/integrations/anr/common.ts b/packages/node/src/integrations/anr/common.ts deleted file mode 100644 index 38583dfacaaf..000000000000 --- a/packages/node/src/integrations/anr/common.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Contexts, DsnComponents, SdkMetadata } from '@sentry/types'; - -export interface Options { - /** - * Interval to send heartbeat messages to the ANR worker. - * - * Defaults to 50ms. - */ - pollInterval: number; - /** - * Threshold in milliseconds to trigger an ANR event. - * - * Defaults to 5000ms. - */ - anrThreshold: number; - /** - * Whether to capture a stack trace when the ANR event is triggered. - * - * Defaults to `false`. - * - * This uses the node debugger which enables the inspector API and opens the required ports. - */ - captureStackTrace: boolean; -} - -export interface WorkerStartData extends Options { - debug: boolean; - sdkMetadata: SdkMetadata; - dsn: DsnComponents; - release: string | undefined; - environment: string; - dist: string | undefined; - contexts: Contexts; -} diff --git a/packages/node/src/integrations/anr/index.ts b/packages/node/src/integrations/anr/index.ts deleted file mode 100644 index c7ff6c53aaac..000000000000 --- a/packages/node/src/integrations/anr/index.ts +++ /dev/null @@ -1,155 +0,0 @@ -// TODO (v8): This import can be removed once we only support Node with global URL -import { URL } from 'url'; -import { getCurrentScope } from '@sentry/core'; -import type { Contexts, Event, EventHint, Integration } from '@sentry/types'; -import { dynamicRequire, logger } from '@sentry/utils'; -import type { Worker, WorkerOptions } from 'worker_threads'; -import type { NodeClient } from '../../client'; -import { NODE_VERSION } from '../../nodeVersion'; -import type { Options, WorkerStartData } from './common'; -import { base64WorkerScript } from './worker-script'; - -const DEFAULT_INTERVAL = 50; -const DEFAULT_HANG_THRESHOLD = 5000; - -type WorkerNodeV14 = Worker & { new (filename: string | URL, options?: WorkerOptions): Worker }; - -type WorkerThreads = { - Worker: WorkerNodeV14; -}; - -function log(message: string, ...args: unknown[]): void { - logger.log(`[ANR] ${message}`, ...args); -} - -/** - * We need to use dynamicRequire because worker_threads is not available in node < v12 and webpack error will when - * targeting those versions - */ -function getWorkerThreads(): WorkerThreads { - return dynamicRequire(module, 'worker_threads'); -} - -/** - * Gets contexts by calling all event processors. This relies on being called after all integrations are setup - */ -async function getContexts(client: NodeClient): Promise { - let event: Event | null = { message: 'ANR' }; - const eventHint: EventHint = {}; - - for (const processor of client.getEventProcessors()) { - if (event === null) break; - event = await processor(event, eventHint); - } - - return event?.contexts || {}; -} - -interface InspectorApi { - open: (port: number) => void; - url: () => string | undefined; -} - -/** - * Starts a thread to detect App Not Responding (ANR) events - */ -export class Anr implements Integration { - public name: string = 'Anr'; - - public constructor(private readonly _options: Partial = {}) {} - - /** @inheritdoc */ - public setupOnce(): void { - // Do nothing - } - - /** @inheritdoc */ - public setup(client: NodeClient): void { - if ((NODE_VERSION.major || 0) < 16) { - throw new Error('ANR detection requires Node 16 or later'); - } - - // setImmediate is used to ensure that all other integrations have been setup - setImmediate(() => this._startWorker(client)); - } - - /** - * Starts the ANR worker thread - */ - private async _startWorker(client: NodeClient): Promise { - const contexts = await getContexts(client); - const dsn = client.getDsn(); - - if (!dsn) { - return; - } - - // These will not be inaccurate if sent later from the worker thread - delete contexts.app?.app_memory; - delete contexts.device?.free_memory; - - const initOptions = client.getOptions(); - - const sdkMetadata = client.getSdkMetadata() || {}; - if (sdkMetadata.sdk) { - sdkMetadata.sdk.integrations = initOptions.integrations.map(i => i.name); - } - - const options: WorkerStartData = { - debug: logger.isEnabled(), - dsn, - environment: initOptions.environment || 'production', - release: initOptions.release, - dist: initOptions.dist, - sdkMetadata, - pollInterval: this._options.pollInterval || DEFAULT_INTERVAL, - anrThreshold: this._options.anrThreshold || DEFAULT_HANG_THRESHOLD, - captureStackTrace: !!this._options.captureStackTrace, - contexts, - }; - - if (options.captureStackTrace) { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const inspector: InspectorApi = require('inspector'); - inspector.open(0); - } - - const { Worker } = getWorkerThreads(); - - const worker = new Worker(new URL(`data:application/javascript;base64,${base64WorkerScript}`), { - workerData: options, - }); - // Ensure this thread can't block app exit - worker.unref(); - - const timer = setInterval(() => { - try { - const currentSession = getCurrentScope().getSession(); - // We need to copy the session object and remove the toJSON method so it can be sent to the worker - // serialized without making it a SerializedSession - const session = currentSession ? { ...currentSession, toJSON: undefined } : undefined; - // message the worker to tell it the main event loop is still running - worker.postMessage({ session }); - } catch (_) { - // - } - }, options.pollInterval); - - worker.on('message', (msg: string) => { - if (msg === 'session-ended') { - log('ANR event sent from ANR worker. Clearing session in this thread.'); - getCurrentScope().setSession(undefined); - } - }); - - worker.once('error', (err: Error) => { - clearInterval(timer); - log('ANR worker error', err); - }); - - worker.once('exit', (code: number) => { - clearInterval(timer); - log('ANR worker exit', code); - }); - } -} diff --git a/packages/node/src/integrations/anr/legacy.ts b/packages/node/src/integrations/anr/legacy.ts deleted file mode 100644 index 1d1ebc3024e3..000000000000 --- a/packages/node/src/integrations/anr/legacy.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { getClient } from '@sentry/core'; -import { Anr } from '.'; -import type { NodeClient } from '../../client'; - -// TODO (v8): Remove this entire file and the `enableAnrDetection` export - -interface LegacyOptions { - entryScript: string; - pollInterval: number; - anrThreshold: number; - captureStackTrace: boolean; - debug: boolean; -} - -/** - * @deprecated Use the `Anr` integration instead. - * - * ```ts - * import * as Sentry from '@sentry/node'; - * - * Sentry.init({ - * dsn: '__DSN__', - * integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true })], - * }); - * ``` - */ -export function enableAnrDetection(options: Partial): Promise { - const client = getClient() as NodeClient; - const integration = new Anr(options); - integration.setup(client); - return Promise.resolve(); -} diff --git a/packages/node/src/integrations/anr/worker.ts b/packages/node/src/integrations/anr/worker.ts deleted file mode 100644 index 142fe0d608e7..000000000000 --- a/packages/node/src/integrations/anr/worker.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { - createEventEnvelope, - createSessionEnvelope, - getEnvelopeEndpointWithUrlEncodedAuth, - makeSession, - updateSession, -} from '@sentry/core'; -import type { Event, Session, StackFrame, TraceContext } from '@sentry/types'; -import { callFrameToStackFrame, stripSentryFramesAndReverse, watchdogTimer } from '@sentry/utils'; -import { Session as InspectorSession } from 'inspector'; -import { parentPort, workerData } from 'worker_threads'; -import { makeNodeTransport } from '../../transports'; -import type { WorkerStartData } from './common'; - -type VoidFunction = () => void; -type InspectorSessionNodeV12 = InspectorSession & { connectToMainThread: VoidFunction }; - -const options: WorkerStartData = workerData; -let session: Session | undefined; -let hasSentAnrEvent = false; - -function log(msg: string): void { - if (options.debug) { - // eslint-disable-next-line no-console - console.log(`[ANR Worker] ${msg}`); - } -} - -const url = getEnvelopeEndpointWithUrlEncodedAuth(options.dsn); -const transport = makeNodeTransport({ - url, - recordDroppedEvent: () => { - // - }, -}); - -async function sendAbnormalSession(): Promise { - // of we have an existing session passed from the main thread, send it as abnormal - if (session) { - log('Sending abnormal session'); - updateSession(session, { status: 'abnormal', abnormal_mechanism: 'anr_foreground' }); - - log(JSON.stringify(session)); - - const envelope = createSessionEnvelope(session, options.dsn, options.sdkMetadata); - await transport.send(envelope); - - try { - // Notify the main process that the session has ended so the session can be cleared from the scope - parentPort?.postMessage('session-ended'); - } catch (_) { - // ignore - } - } -} - -log('Started'); - -async function sendAnrEvent(frames?: StackFrame[], traceContext?: TraceContext): Promise { - if (hasSentAnrEvent) { - return; - } - - hasSentAnrEvent = true; - - await sendAbnormalSession(); - - log('Sending event'); - - const event: Event = { - sdk: options.sdkMetadata.sdk, - contexts: { ...options.contexts, trace: traceContext }, - release: options.release, - environment: options.environment, - dist: options.dist, - platform: 'node', - level: 'error', - exception: { - values: [ - { - type: 'ApplicationNotResponding', - value: `Application Not Responding for at least ${options.anrThreshold} ms`, - stacktrace: { frames }, - // This ensures the UI doesn't say 'Crashed in' for the stack trace - mechanism: { type: 'ANR' }, - }, - ], - }, - tags: { 'process.name': 'ANR' }, - }; - - log(JSON.stringify(event)); - - const envelope = createEventEnvelope(event, options.dsn, options.sdkMetadata); - await transport.send(envelope); - await transport.flush(2000); - - // Delay for 5 seconds so that stdio can flush in the main event loop ever restarts. - // This is mainly for the benefit of logging/debugging issues. - setTimeout(() => { - process.exit(0); - }, 5_000); -} - -let debuggerPause: VoidFunction | undefined; - -if (options.captureStackTrace) { - log('Connecting to debugger'); - - const session = new InspectorSession() as InspectorSessionNodeV12; - session.connectToMainThread(); - - log('Connected to debugger'); - - // Collect scriptId -> url map so we can look up the filenames later - const scripts = new Map(); - - session.on('Debugger.scriptParsed', event => { - scripts.set(event.params.scriptId, event.params.url); - }); - - session.on('Debugger.paused', event => { - if (event.params.reason !== 'other') { - return; - } - - try { - log('Debugger paused'); - - // copy the frames - const callFrames = [...event.params.callFrames]; - - const stackFrames = stripSentryFramesAndReverse( - callFrames.map(frame => callFrameToStackFrame(frame, scripts.get(frame.location.scriptId), () => undefined)), - ); - - // Evaluate a script in the currently paused context - session.post( - 'Runtime.evaluate', - { - // Grab the trace context from the current scope - expression: - 'const ctx = __SENTRY__.hub.getScope().getPropagationContext(); ctx.traceId + "-" + ctx.spanId + "-" + ctx.parentSpanId', - // Don't re-trigger the debugger if this causes an error - silent: true, - }, - (_, param) => { - const traceId = param && param.result ? (param.result.value as string) : '--'; - const [trace_id, span_id, parent_span_id] = traceId.split('-') as (string | undefined)[]; - - session.post('Debugger.resume'); - session.post('Debugger.disable'); - - const context = trace_id?.length && span_id?.length ? { trace_id, span_id, parent_span_id } : undefined; - sendAnrEvent(stackFrames, context).then(null, () => { - log('Sending ANR event failed.'); - }); - }, - ); - } catch (e) { - session.post('Debugger.resume'); - session.post('Debugger.disable'); - throw e; - } - }); - - debuggerPause = () => { - try { - session.post('Debugger.enable', () => { - session.post('Debugger.pause'); - }); - } catch (_) { - // - } - }; -} - -function createHrTimer(): { getTimeMs: () => number; reset: VoidFunction } { - // TODO (v8): We can use process.hrtime.bigint() after we drop node v8 - let lastPoll = process.hrtime(); - - return { - getTimeMs: (): number => { - const [seconds, nanoSeconds] = process.hrtime(lastPoll); - return Math.floor(seconds * 1e3 + nanoSeconds / 1e6); - }, - reset: (): void => { - lastPoll = process.hrtime(); - }, - }; -} - -function watchdogTimeout(): void { - log('Watchdog timeout'); - - if (debuggerPause) { - log('Pausing debugger to capture stack trace'); - debuggerPause(); - } else { - log('Capturing event without a stack trace'); - sendAnrEvent().then(null, () => { - log('Sending ANR event failed on watchdog timeout.'); - }); - } -} - -const { poll } = watchdogTimer(createHrTimer, options.pollInterval, options.anrThreshold, watchdogTimeout); - -parentPort?.on('message', (msg: { session: Session | undefined }) => { - if (msg.session) { - session = makeSession(msg.session); - } - - poll(); -}); diff --git a/packages/node/src/integrations/index.ts b/packages/node/src/integrations/index.ts index 63cf685d2bc5..f2ac9c25b807 100644 --- a/packages/node/src/integrations/index.ts +++ b/packages/node/src/integrations/index.ts @@ -9,5 +9,4 @@ export { RequestData } from '@sentry/core'; export { LocalVariables } from './localvariables'; export { Undici } from './undici'; export { Spotlight } from './spotlight'; -export { Anr } from './anr'; export { Hapi } from './hapi'; diff --git a/packages/node/src/sdk.ts b/packages/node/src/sdk.ts index c9a1c108dfdd..07fd3f8b024a 100644 --- a/packages/node/src/sdk.ts +++ b/packages/node/src/sdk.ts @@ -17,6 +17,7 @@ import { tracingContextFromHeaders, } from '@sentry/utils'; +import { isAnrChildProcess } from './anr'; import { setNodeAsyncContextStrategy } from './async'; import { NodeClient } from './client'; import { @@ -113,6 +114,11 @@ export const defaultIntegrations = [ */ // eslint-disable-next-line complexity export function init(options: NodeOptions = {}): void { + if (isAnrChildProcess()) { + options.autoSessionTracking = false; + options.tracesSampleRate = 0; + } + const carrier = getMainCarrier(); setNodeAsyncContextStrategy(); diff --git a/packages/utils/src/anr.ts b/packages/utils/src/anr.ts index d962107bcb0e..89990c3414f7 100644 --- a/packages/utils/src/anr.ts +++ b/packages/utils/src/anr.ts @@ -1,7 +1,7 @@ import type { StackFrame } from '@sentry/types'; import { dropUndefinedKeys } from './object'; -import { filenameIsInApp } from './stacktrace'; +import { filenameIsInApp, stripSentryFramesAndReverse } from './stacktrace'; type WatchdogReturn = { /** Resets the watchdog timer */ @@ -67,10 +67,20 @@ interface CallFrame { url: string; } +interface ScriptParsedEventDataType { + scriptId: string; + url: string; +} + +interface PausedEventDataType { + callFrames: CallFrame[]; + reason: string; +} + /** * Converts Debugger.CallFrame to Sentry StackFrame */ -export function callFrameToStackFrame( +function callFrameToStackFrame( frame: CallFrame, url: string | undefined, getModuleFromFilename: (filename: string | undefined) => string | undefined, @@ -90,3 +100,40 @@ export function callFrameToStackFrame( in_app: filename ? filenameIsInApp(filename) : undefined, }); } + +// The only messages we care about +type DebugMessage = + | { method: 'Debugger.scriptParsed'; params: ScriptParsedEventDataType } + | { method: 'Debugger.paused'; params: PausedEventDataType }; + +/** + * Creates a message handler from the v8 debugger protocol and passed stack frames to the callback when paused. + */ +export function createDebugPauseMessageHandler( + sendCommand: (message: string) => void, + getModuleFromFilename: (filename?: string) => string | undefined, + pausedStackFrames: (frames: StackFrame[]) => void, +): (message: DebugMessage) => void { + // Collect scriptId -> url map so we can look up the filenames later + const scripts = new Map(); + + return message => { + if (message.method === 'Debugger.scriptParsed') { + scripts.set(message.params.scriptId, message.params.url); + } else if (message.method === 'Debugger.paused') { + // copy the frames + const callFrames = [...message.params.callFrames]; + // and resume immediately + sendCommand('Debugger.resume'); + sendCommand('Debugger.disable'); + + const stackFrames = stripSentryFramesAndReverse( + callFrames.map(frame => + callFrameToStackFrame(frame, scripts.get(frame.location.scriptId), getModuleFromFilename), + ), + ); + + pausedStackFrames(stackFrames); + } + }; +} diff --git a/rollup/bundleHelpers.js b/rollup/bundleHelpers.js index 104c0c61e83b..cd329dfb31d2 100644 --- a/rollup/bundleHelpers.js +++ b/rollup/bundleHelpers.js @@ -102,15 +102,6 @@ export function makeBaseBundleConfig(options) { external: builtinModules, }; - const workerBundleConfig = { - output: { - format: 'esm', - }, - plugins: [commonJSPlugin, makeTerserPlugin()], - // Don't bundle any of Node's core modules - external: builtinModules, - }; - // used by all bundles const sharedBundleConfig = { input: entrypoints, @@ -132,7 +123,6 @@ export function makeBaseBundleConfig(options) { standalone: standAloneBundleConfig, addon: addOnBundleConfig, node: nodeBundleConfig, - 'node-worker': workerBundleConfig, }; return deepMerge.all([sharedBundleConfig, bundleTypeConfigMap[bundleType], packageSpecificConfig || {}], { From e50887249376b29ad6e134496d6ac9cbf537980e Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 20 Dec 2023 13:31:59 +0000 Subject: [PATCH 2/2] Fix floating promise --- packages/node/src/anr/index.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/node/src/anr/index.ts b/packages/node/src/anr/index.ts index 32117f21372b..13ac5c52c6ef 100644 --- a/packages/node/src/anr/index.ts +++ b/packages/node/src/anr/index.ts @@ -183,10 +183,16 @@ function handleChildProcess(options: Options): void { captureEvent(createAnrEvent(options.anrThreshold, frames)); - void flush(3000).then(() => { - // We only capture one event to avoid spamming users with errors - process.exit(); - }); + flush(3000).then( + () => { + // We only capture one event to avoid spamming users with errors + process.exit(); + }, + () => { + // We only capture one event to avoid spamming users with errors + process.exit(); + }, + ); } addEventProcessor(event => {