diff --git a/packages/node-integration-tests/suites/anr/basic-session.js b/packages/node-integration-tests/suites/anr/basic-session.js index 29cdc17e76c9..cfb2b7f05190 100644 --- a/packages/node-integration-tests/suites/anr/basic-session.js +++ b/packages/node-integration-tests/suites/anr/basic-session.js @@ -4,10 +4,9 @@ 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', diff --git a/packages/node-integration-tests/suites/anr/basic.js b/packages/node-integration-tests/suites/anr/basic.js index 33c4151a19f1..563488beaf5a 100644 --- a/packages/node-integration-tests/suites/anr/basic.js +++ b/packages/node-integration-tests/suites/anr/basic.js @@ -4,10 +4,9 @@ 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', diff --git a/packages/node-integration-tests/suites/anr/basic.mjs b/packages/node-integration-tests/suites/anr/basic.mjs index 3d10dc556076..c03339411acb 100644 --- a/packages/node-integration-tests/suites/anr/basic.mjs +++ b/packages/node-integration-tests/suites/anr/basic.mjs @@ -4,10 +4,9 @@ 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', diff --git a/packages/node-integration-tests/suites/anr/forked.js b/packages/node-integration-tests/suites/anr/forked.js index 33c4151a19f1..563488beaf5a 100644 --- a/packages/node-integration-tests/suites/anr/forked.js +++ b/packages/node-integration-tests/suites/anr/forked.js @@ -4,10 +4,9 @@ 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', diff --git a/packages/node-integration-tests/suites/anr/test.ts b/packages/node-integration-tests/suites/anr/test.ts index 0c815c280f00..de4032479f03 100644 --- a/packages/node-integration-tests/suites/anr/test.ts +++ b/packages/node-integration-tests/suites/anr/test.ts @@ -26,10 +26,12 @@ function parseJsonLines(input: string, expected: number): T describe('should report ANR when event loop blocked', () => { test('CJS', done => { - // The stack trace is different when node < 12 - const testFramesDetails = NODE_VERSION >= 12; + if (NODE_VERSION < 12) { + done(); + return; + } - expect.assertions(testFramesDetails ? 7 : 5); + expect.assertions(9); const testScriptPath = path.resolve(__dirname, 'basic.js'); @@ -41,10 +43,11 @@ 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(); done(); }); @@ -66,7 +69,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 +78,12 @@ 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; + if (NODE_VERSION < 12) { + done(); + return; + } - expect.assertions(testFramesDetails ? 9 : 7); + expect.assertions(9); const testScriptPath = path.resolve(__dirname, 'basic-session.js'); @@ -90,10 +95,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 +106,12 @@ 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; + if (NODE_VERSION < 12) { + done(); + return; + } - expect.assertions(testFramesDetails ? 7 : 5); + expect.assertions(7); const testScriptPath = path.resolve(__dirname, 'forker.js'); @@ -118,10 +123,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/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 index 84a3ebe52920..1d838ab6ad62 100644 --- a/packages/node/src/anr/index.ts +++ b/packages/node/src/anr/index.ts @@ -1,23 +1,30 @@ -import { spawn } from 'child_process'; import { getClient, makeSession, updateSession } from '@sentry/core'; -import type { Event, Session, StackFrame } from '@sentry/types'; -import { logger, watchdogTimer } from '@sentry/utils'; - -import { addEventProcessor, captureEvent, flush, getCurrentHub } from '..'; -import { captureStackTrace } from './debugger'; +import type { Event, Session, StackFrame, TraceContext } from '@sentry/types'; +import { + callFrameToStackFrame, + dynamicRequire, + logger, + stripSentryFramesAndReverse, + watchdogTimer, +} from '@sentry/utils'; +import type { Session as InspectorSession } from 'inspector'; + +import type { MessagePort, Worker } from 'worker_threads'; +import { addEventProcessor, captureEvent, flush, getCurrentHub, getModuleFromFilename } from '..'; +import { NODE_VERSION } from '../nodeVersion'; 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. + * The app entry script. This is used to run the same script as the ANR worker. * * Defaults to `process.argv[1]`. */ entryScript: string; /** - * Interval to send heartbeat messages to the child process. + * Interval to send heartbeat messages to the ANR worker. * * Defaults to 50ms. */ @@ -61,6 +68,23 @@ function createAnrEvent(blockedMs: number, frames?: StackFrame[]): Event { }; } +type WorkerThreads = { + Worker: typeof Worker; + isMainThread: boolean; + parentPort: null | MessagePort; + workerData: { inspectorUrl?: string }; +}; + +/** + * 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'); +} + +type InspectorSessionNodeV12 = InspectorSession & { connectToMainThread: () => void }; + interface InspectorApi { open: (port: number) => void; url: () => string | undefined; @@ -86,7 +110,9 @@ function startInspector(startPort: number = 9229): string | undefined { return inspectorUrl; } -function startChildProcess(options: Options): void { +function startAnrWorker(options: Options): void { + const { Worker } = getWorkerThreads(); + function log(message: string, ...args: unknown[]): void { logger.log(`[ANR] ${message}`, ...args); } @@ -94,54 +120,44 @@ function startChildProcess(options: Options): void { const hub = getCurrentHub(); 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}'`); + log(`Spawning worker with 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 inspectorUrl = options.captureStackTrace ? startInspector() : undefined; + const worker = new Worker(options.entryScript, { workerData: { inspectorUrl } }); + // The worker should not keep the main process alive + worker.unref(); const timer = setInterval(() => { try { const currentSession = hub.getScope()?.getSession(); - // We need to copy the session object and remove the toJSON method so it can be sent to the child process + // 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 child process to tell it the main event loop is still running - child.send({ session }); + // message the worker to tell it the main event loop is still running + worker.postMessage({ session }); } catch (_) { // } }, options.pollInterval); - child.on('message', (msg: string) => { + worker.on('message', (msg: string) => { if (msg === 'session-ended') { - log('ANR event sent from child process. Clearing session in this process.'); + log('ANR event sent from ANR worker. Clearing session in this thread.'); hub.getScope()?.setSession(undefined); } }); - const end = (type: string): ((...args: unknown[]) => void) => { - return (...args): void => { - clearInterval(timer); - log(`Child process ${type}`, ...args); - }; - }; + worker.once('error', (err: Error) => { + clearInterval(timer); + log('ANR worker error', err); + }); - child.on('error', end('error')); - child.on('disconnect', end('disconnect')); - child.on('exit', end('exit')); + worker.once('exit', (code: number) => { + clearInterval(timer); + log('ANR worker exit', code); + }); } catch (e) { - log('Failed to start child process', e); + log('Failed to start worker', e); } } @@ -159,17 +175,27 @@ function createHrTimer(): { getTimeMs: () => number; reset: () => void } { }; } -function handleChildProcess(options: Options): void { - process.title = 'sentry-anr'; +function handlerAnrWorker(options: Options): void { + const { parentPort, workerData } = getWorkerThreads(); + + let anrEventSent = false; function log(message: string): void { - logger.log(`[ANR child process] ${message}`); + logger.log(`[ANR Worker] ${message}`); } log('Started'); let session: Session | undefined; - function sendAnrEvent(frames?: StackFrame[]): void { + function sendAnrEvent(frames?: StackFrame[], traceContext?: TraceContext): void { + if (anrEventSent) { + return; + } + + anrEventSent = true; + + log('Sending ANR event'); + if (session) { log('Sending abnormal session'); updateSession(session, { status: 'abnormal', abnormal_mechanism: 'anr_foreground' }); @@ -177,22 +203,27 @@ function handleChildProcess(options: Options): void { try { // Notify the main process that the session has ended so the session can be cleared from the scope - process.send?.('session-ended'); + parentPort?.postMessage('session-ended'); } catch (_) { // ignore } } - captureEvent(createAnrEvent(options.anrThreshold, frames)); + captureEvent(createAnrEvent(options.anrThreshold, frames), { + captureContext: { contexts: { trace: traceContext } }, + }); void flush(3000).then(() => { - // We only capture one event to avoid spamming users with errors - process.exit(); + // We exit so we only capture one event to avoid spamming users with errors + // We wait 5 seconds to ensure stdio has been flushed from the worker + setTimeout(() => { + process.exit(); + }, 5_000); }); } addEventProcessor(event => { - // Strip sdkProcessingMetadata from all child process events to remove trace info + // Strip sdkProcessingMetadata from all ANR worker events to remove trace info delete event.sdkProcessingMetadata; event.tags = { ...event.tags, @@ -201,65 +232,117 @@ function handleChildProcess(options: Options): void { return event; }); - let debuggerPause: Promise<() => void> | undefined; + let debuggerPause: () => void | undefined; - // if attachStackTrace is enabled, we'll have a debugger url to connect to - if (process.env.SENTRY_INSPECT_URL) { + const { inspectorUrl } = workerData; + + // if attachStackTrace was enabled, we'll have a debugger url to connect to + if (inspectorUrl) { log('Connecting to debugger'); - debuggerPause = captureStackTrace(process.env.SENTRY_INSPECT_URL, frames => { - log('Capturing event with stack frames'); - sendAnrEvent(frames); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { Session } = require('inspector'); + const session: InspectorSessionNodeV12 = new Session(); + session.connectToMainThread(); + + // 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; + } + + // copy the frames + const callFrames = [...event.params.callFrames]; + + const stackFrames = stripSentryFramesAndReverse( + callFrames.map(frame => + callFrameToStackFrame(frame, scripts.get(frame.location.scriptId), getModuleFromFilename), + ), + ); + + // 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', + // 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] = traceId.split('-'); + + // Resume immediately + session.post('Debugger.resume'); + session.post('Debugger.disable'); + + const context = trace_id && span_id ? { trace_id, span_id } : undefined; + sendAnrEvent(stackFrames, context); + }, + ); }); + + debuggerPause = () => { + session.post('Debugger.enable', () => { + session.post('Debugger.pause'); + }); + }; } async function watchdogTimeout(): Promise { log('Watchdog timeout'); try { - const pauseAndCapture = await debuggerPause; - - if (pauseAndCapture) { + if (debuggerPause) { log('Pausing debugger to capture stack trace'); - pauseAndCapture(); + debuggerPause(); return; } } catch (_) { // ignore } - log('Capturing event'); + log('Capturing event without a stack trace'); sendAnrEvent(); } const { poll } = watchdogTimer(createHrTimer, options.pollInterval, options.anrThreshold, watchdogTimeout); - process.on('message', (msg: { session: Session | undefined }) => { + parentPort?.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. + * Returns true if the current thread is the ANR worker. */ -export function isAnrChildProcess(): boolean { - return !!process.send && !!process.env.SENTRY_ANR_CHILD_PROCESS; +export function isAnrWorker(): boolean { + try { + const { isMainThread } = getWorkerThreads(); + return !isMainThread; + } catch (_) { + return false; + } } /** * **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. + * Starts a ANR worker 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. + * It's important to await on the returned promise before your app code to ensure this code does not run in the + * ANR worker. * * ```js * import { init, enableAnrDetection } from '@sentry/node'; @@ -277,6 +360,10 @@ export function isAnrChildProcess(): boolean { * ``` */ export function enableAnrDetection(options: Partial): Promise { + if ((NODE_VERSION.major || 0) < 12 || ((NODE_VERSION.major || 0) === 12 && (NODE_VERSION.minor || 0) < 11)) { + throw new Error('ANR detection requires Node 12.11.0 or later'); + } + // 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]; @@ -290,14 +377,14 @@ export function enableAnrDetection(options: Partial): Promise { debug: !!options.debug, }; - if (isAnrChildProcess()) { - handleChildProcess(anrOptions); - // In the child process, the promise never resolves which stops the app code from running + if (isAnrWorker()) { + handlerAnrWorker(anrOptions); + // In the ANR worker, the promise never resolves which stops the app code from running return new Promise(() => { // Never resolve }); } else { - startChildProcess(anrOptions); + startAnrWorker(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/sdk.ts b/packages/node/src/sdk.ts index 5ef42128d9ab..d8498ef7ecde 100644 --- a/packages/node/src/sdk.ts +++ b/packages/node/src/sdk.ts @@ -15,7 +15,7 @@ import { tracingContextFromHeaders, } from '@sentry/utils'; -import { isAnrChildProcess } from './anr'; +import { isAnrWorker } from './anr'; import { setNodeAsyncContextStrategy } from './async'; import { NodeClient } from './client'; import { @@ -112,7 +112,7 @@ export const defaultIntegrations = [ */ // eslint-disable-next-line complexity export function init(options: NodeOptions = {}): void { - if (isAnrChildProcess()) { + if (isAnrWorker()) { options.autoSessionTracking = false; options.tracesSampleRate = 0; } diff --git a/packages/utils/src/anr.ts b/packages/utils/src/anr.ts index 89990c3414f7..5312d5632ac9 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 */ @@ -80,7 +80,7 @@ interface PausedEventDataType { /** * Converts Debugger.CallFrame to Sentry StackFrame */ -function callFrameToStackFrame( +export function callFrameToStackFrame( frame: CallFrame, url: string | undefined, getModuleFromFilename: (filename: string | undefined) => string | undefined, @@ -100,40 +100,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); - } - }; -}