diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 114ac4a670fe..08f18f38ade6 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 } from './envelope'; +export { createEventEnvelope, createSessionEnvelope } from './envelope'; export { addBreadcrumb, captureCheckIn, diff --git a/packages/node-experimental/src/sdk/init.ts b/packages/node-experimental/src/sdk/init.ts index e7c6ebf72381..821757a9a246 100644 --- a/packages/node-experimental/src/sdk/init.ts +++ b/packages/node-experimental/src/sdk/init.ts @@ -4,7 +4,6 @@ import { defaultIntegrations as defaultNodeIntegrations, defaultStackParser, getSentryRelease, - isAnrChildProcess, makeNodeTransport, } from '@sentry/node'; import type { Integration } from '@sentry/types'; @@ -113,15 +112,14 @@ 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' || isAnrChildProcess() + typeof release !== 'string' ? false : options.autoSessionTracking === undefined ? true : options.autoSessionTracking; - // We enforce tracesSampleRate = 0 in ANR child processes - const tracesSampleRate = isAnrChildProcess() ? 0 : getTracesSampleRate(options.tracesSampleRate); + + const tracesSampleRate = 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 29cdc17e76c9..03c8c94fdadf 100644 --- a/packages/node-integration-tests/suites/anr/basic-session.js +++ b/packages/node-integration-tests/suites/anr/basic-session.js @@ -1,31 +1,28 @@ 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(); -}, 5000); +}, 10000); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', debug: true, - transport, + integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 200 })], }); -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'); - } +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); -}); +setTimeout(() => { + longWork(); +}, 1000); diff --git a/packages/node-integration-tests/suites/anr/basic.js b/packages/node-integration-tests/suites/anr/basic.js index 33c4151a19f1..5e0323e2c6c5 100644 --- a/packages/node-integration-tests/suites/anr/basic.js +++ b/packages/node-integration-tests/suites/anr/basic.js @@ -1,32 +1,29 @@ 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(); -}, 5000); +}, 10000); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', debug: true, autoSessionTracking: false, - transport, + integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 200 })], }); -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'); - } +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); -}); +setTimeout(() => { + longWork(); +}, 1000); diff --git a/packages/node-integration-tests/suites/anr/basic.mjs b/packages/node-integration-tests/suites/anr/basic.mjs index 3d10dc556076..17c8a2d460df 100644 --- a/packages/node-integration-tests/suites/anr/basic.mjs +++ b/packages/node-integration-tests/suites/anr/basic.mjs @@ -1,29 +1,26 @@ +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(); -}, 5000); +}, 10000); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', debug: true, autoSessionTracking: false, - transport, + integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 200 })], }); -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 33c4151a19f1..5e0323e2c6c5 100644 --- a/packages/node-integration-tests/suites/anr/forked.js +++ b/packages/node-integration-tests/suites/anr/forked.js @@ -1,32 +1,29 @@ 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(); -}, 5000); +}, 10000); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', debug: true, autoSessionTracking: false, - transport, + integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 200 })], }); -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'); - } +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); -}); +setTimeout(() => { + longWork(); +}, 1000); diff --git a/packages/node-integration-tests/suites/anr/legacy.js b/packages/node-integration-tests/suites/anr/legacy.js new file mode 100644 index 000000000000..46b6e1437b10 --- /dev/null +++ b/packages/node-integration-tests/suites/anr/legacy.js @@ -0,0 +1,31 @@ +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 deleted file mode 100644 index 86836cd6ab35..000000000000 --- a/packages/node-integration-tests/suites/anr/test-transport.js +++ /dev/null @@ -1,17 +0,0 @@ -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 0c815c280f00..a070f611a0ab 100644 --- a/packages/node-integration-tests/suites/anr/test.ts +++ b/packages/node-integration-tests/suites/anr/test.ts @@ -2,17 +2,16 @@ import * as childProcess from 'child_process'; import * as path from 'path'; import type { Event } from '@sentry/node'; import type { SerializedSession } from '@sentry/types'; -import { parseSemver } from '@sentry/utils'; - -const NODE_VERSION = parseSemver(process.versions.node).major || 0; +import { conditionalTest } from '../../utils'; /** 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(line) as T; + return JSON.parse(trimmed) as T; } catch { return undefined; } @@ -24,12 +23,9 @@ function parseJsonLines(input: string, expected: number): T return results; } -describe('should report ANR when event loop blocked', () => { +conditionalTest({ min: 16 })('should report ANR when event loop blocked', () => { test('CJS', done => { - // The stack trace is different when node < 12 - const testFramesDetails = NODE_VERSION >= 12; - - expect.assertions(testFramesDetails ? 7 : 5); + expect.assertions(13); const testScriptPath = path.resolve(__dirname, 'basic.js'); @@ -41,21 +37,46 @@ describe('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); - if (testFramesDetails) { - expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); - expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); - } + 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(); done(); }); }); - test('ESM', done => { - if (NODE_VERSION < 14) { + 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(); + done(); - return; - } + }); + }); + test('ESM', done => { expect.assertions(7); const testScriptPath = path.resolve(__dirname, 'basic.mjs'); @@ -66,7 +87,7 @@ describe('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).toBeGreaterThan(4); + expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThanOrEqual(4); expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); @@ -75,10 +96,7 @@ describe('should report ANR when event loop blocked', () => { }); test('With session', done => { - // The stack trace is different when node < 12 - const testFramesDetails = NODE_VERSION >= 12; - - expect.assertions(testFramesDetails ? 9 : 7); + expect.assertions(9); const testScriptPath = path.resolve(__dirname, 'basic-session.js'); @@ -90,10 +108,8 @@ describe('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); - if (testFramesDetails) { - expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); - expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); - } + 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'); @@ -103,10 +119,7 @@ describe('should report ANR when event loop blocked', () => { }); test('from forked process', done => { - // The stack trace is different when node < 12 - const testFramesDetails = NODE_VERSION >= 12; - - expect.assertions(testFramesDetails ? 7 : 5); + expect.assertions(7); const testScriptPath = path.resolve(__dirname, 'forker.js'); @@ -118,10 +131,8 @@ describe('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); - if (testFramesDetails) { - expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); - expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); - } + 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/rollup.anr-worker.config.mjs b/packages/node/rollup.anr-worker.config.mjs new file mode 100644 index 000000000000..9887342c63fd --- /dev/null +++ b/packages/node/rollup.anr-worker.config.mjs @@ -0,0 +1,35 @@ +import { makeBaseBundleConfig } from '@sentry-internal/rollup-utils'; + +function createAnrWorkerConfig(destDir, esm) { + return makeBaseBundleConfig({ + bundleType: 'node-worker', + entrypoints: ['src/integrations/anr/worker.ts'], + jsVersion: 'es6', + licenseTitle: '@sentry/node', + outputFileBase: () => 'worker-script.js', + packageSpecificConfig: { + output: { + dir: destDir, + sourcemap: false, + }, + plugins: [ + { + name: 'output-base64-worker-script', + renderChunk(code) { + const base64Code = Buffer.from(code).toString('base64'); + if (esm) { + return `export const base64WorkerScript = '${base64Code}';`; + } else { + return `exports.base64WorkerScript = '${base64Code}';`; + } + }, + }, + ], + }, + }); +} + +export const anrWorkerConfigs = [ + createAnrWorkerConfig('build/esm/integrations/anr', true), + createAnrWorkerConfig('build/cjs/integrations/anr', false), +]; diff --git a/packages/node/rollup.npm.config.mjs b/packages/node/rollup.npm.config.mjs index 84a06f2fb64a..88c90de4825f 100644 --- a/packages/node/rollup.npm.config.mjs +++ b/packages/node/rollup.npm.config.mjs @@ -1,3 +1,8 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; +import { anrWorkerConfigs } from './rollup.anr-worker.config.mjs'; -export default makeNPMConfigVariants(makeBaseNPMConfig()); +export default [ + ...makeNPMConfigVariants(makeBaseNPMConfig()), + // The ANR worker builds must come after the main build because they overwrite the worker-script.js file + ...anrWorkerConfigs, +]; diff --git a/packages/node/src/anr/debugger.ts b/packages/node/src/anr/debugger.ts deleted file mode 100644 index 01b2a90fe0f9..000000000000 --- a/packages/node/src/anr/debugger.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 13ac5c52c6ef..000000000000 --- a/packages/node/src/anr/index.ts +++ /dev/null @@ -1,308 +0,0 @@ -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)); - - 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 => { - // 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 deleted file mode 100644 index 7229f0fc07e7..000000000000 --- a/packages/node/src/anr/websocket.ts +++ /dev/null @@ -1,366 +0,0 @@ -/* 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 06524bcd0c0a..af73f34df7bc 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -79,7 +79,8 @@ export { defaultIntegrations, init, defaultStackParser, getSentryRelease } from export { addRequestDataToEvent, DEFAULT_USER_INCLUDES, extractRequestData } from '@sentry/utils'; export { deepReadDirSync } from './utils'; export { getModuleFromFilename } from './module'; -export { enableAnrDetection, isAnrChildProcess } from './anr'; +// eslint-disable-next-line deprecation/deprecation +export { enableAnrDetection } from './integrations/anr/legacy'; 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 new file mode 100644 index 000000000000..38583dfacaaf --- /dev/null +++ b/packages/node/src/integrations/anr/common.ts @@ -0,0 +1,34 @@ +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 new file mode 100644 index 000000000000..4a623fb3ff0b --- /dev/null +++ b/packages/node/src/integrations/anr/index.ts @@ -0,0 +1,155 @@ +// 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 accurate 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 new file mode 100644 index 000000000000..1d1ebc3024e3 --- /dev/null +++ b/packages/node/src/integrations/anr/legacy.ts @@ -0,0 +1,32 @@ +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-script.ts b/packages/node/src/integrations/anr/worker-script.ts new file mode 100644 index 000000000000..16394eaacfe1 --- /dev/null +++ b/packages/node/src/integrations/anr/worker-script.ts @@ -0,0 +1,2 @@ +// This file is a placeholder that gets overwritten in the build directory. +export const base64WorkerScript = ''; diff --git a/packages/node/src/integrations/anr/worker.ts b/packages/node/src/integrations/anr/worker.ts new file mode 100644 index 000000000000..142fe0d608e7 --- /dev/null +++ b/packages/node/src/integrations/anr/worker.ts @@ -0,0 +1,215 @@ +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 f2ac9c25b807..63cf685d2bc5 100644 --- a/packages/node/src/integrations/index.ts +++ b/packages/node/src/integrations/index.ts @@ -9,4 +9,5 @@ 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 07fd3f8b024a..c9a1c108dfdd 100644 --- a/packages/node/src/sdk.ts +++ b/packages/node/src/sdk.ts @@ -17,7 +17,6 @@ import { tracingContextFromHeaders, } from '@sentry/utils'; -import { isAnrChildProcess } from './anr'; import { setNodeAsyncContextStrategy } from './async'; import { NodeClient } from './client'; import { @@ -114,11 +113,6 @@ 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/rollup-utils/bundleHelpers.mjs b/packages/rollup-utils/bundleHelpers.mjs index 93a84670a6ff..b6ca7c8fcbc7 100644 --- a/packages/rollup-utils/bundleHelpers.mjs +++ b/packages/rollup-utils/bundleHelpers.mjs @@ -102,6 +102,15 @@ 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, @@ -123,6 +132,7 @@ export function makeBaseBundleConfig(options) { standalone: standAloneBundleConfig, addon: addOnBundleConfig, node: nodeBundleConfig, + 'node-worker': workerBundleConfig, }; return deepMerge.all([sharedBundleConfig, bundleTypeConfigMap[bundleType], packageSpecificConfig || {}], { diff --git a/packages/utils/src/anr.ts b/packages/utils/src/anr.ts index 89990c3414f7..d962107bcb0e 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, stripSentryFramesAndReverse } from './stacktrace'; +import { filenameIsInApp } from './stacktrace'; type WatchdogReturn = { /** Resets the watchdog timer */ @@ -67,20 +67,10 @@ interface CallFrame { url: string; } -interface ScriptParsedEventDataType { - scriptId: string; - url: string; -} - -interface PausedEventDataType { - callFrames: CallFrame[]; - reason: string; -} - /** * Converts Debugger.CallFrame to Sentry StackFrame */ -function callFrameToStackFrame( +export function callFrameToStackFrame( frame: CallFrame, url: string | undefined, getModuleFromFilename: (filename: string | undefined) => string | undefined, @@ -100,40 +90,3 @@ 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); - } - }; -}