diff --git a/packages/node/src/anr/debugger.ts b/packages/node/src/anr/debugger.ts index 4d4a2799fa64..01b2a90fe0f9 100644 --- a/packages/node/src/anr/debugger.ts +++ b/packages/node/src/anr/debugger.ts @@ -1,33 +1,10 @@ import type { StackFrame } from '@sentry/types'; -import { dropUndefinedKeys, filenameIsInApp } from '@sentry/utils'; +import { createDebugPauseMessageHandler } from '@sentry/utils'; import type { Debugger } from 'inspector'; import { getModuleFromFilename } from '../module'; import { createWebSocketClient } from './websocket'; -/** - * Converts Debugger.CallFrame to Sentry StackFrame - */ -function callFrameToStackFrame( - frame: Debugger.CallFrame, - filenameFromScriptId: (id: string) => string | undefined, -): StackFrame { - const filename = filenameFromScriptId(frame.location.scriptId)?.replace(/^file:\/\//, ''); - - // CallFrame row/col are 0 based, whereas StackFrame are 1 based - const colno = frame.location.columnNumber ? frame.location.columnNumber + 1 : undefined; - const lineno = frame.location.lineNumber ? frame.location.lineNumber + 1 : undefined; - - return dropUndefinedKeys({ - filename, - module: getModuleFromFilename(filename), - function: frame.functionName || '?', - colno, - lineno, - in_app: filename ? filenameIsInApp(filename) : undefined, - }); -} - // The only messages we care about type DebugMessage = | { @@ -45,7 +22,7 @@ type DebugMessage = async function webSocketDebugger( url: string, onMessage: (message: DebugMessage) => void, -): Promise<(method: string, params?: unknown) => void> { +): Promise<(method: string) => void> { let id = 0; const webSocket = await createWebSocketClient(url); @@ -54,8 +31,8 @@ async function webSocketDebugger( onMessage(message); }); - return (method: string, params?: unknown) => { - webSocket.send(JSON.stringify({ id: id++, method, params })); + return (method: string) => { + webSocket.send(JSON.stringify({ id: id++, method })); }; } @@ -66,27 +43,10 @@ async function webSocketDebugger( * @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> { - // Collect scriptId -> url map so we can look up the filenames later - const scripts = new Map(); - - const sendCommand = await webSocketDebugger(url, 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 frames = callFrames - .map(frame => callFrameToStackFrame(frame, id => scripts.get(id))) - // Sentry expects the frames to be in the opposite order - .reverse(); - - callback(frames); - } - }); + const sendCommand: (method: string) => void = await webSocketDebugger( + url, + createDebugPauseMessageHandler(cmd => sendCommand(cmd), getModuleFromFilename, callback), + ); return () => { sendCommand('Debugger.enable'); diff --git a/packages/node/src/anr/index.ts b/packages/node/src/anr/index.ts index 99bb5901b4ea..38aa697457dd 100644 --- a/packages/node/src/anr/index.ts +++ b/packages/node/src/anr/index.ts @@ -1,5 +1,5 @@ import type { Event, StackFrame } from '@sentry/types'; -import { logger } from '@sentry/utils'; +import { logger, watchdogTimer } from '@sentry/utils'; import { spawn } from 'child_process'; import { addGlobalEventProcessor, captureEvent, flush } from '..'; @@ -8,36 +8,6 @@ import { captureStackTrace } from './debugger'; const DEFAULT_INTERVAL = 50; const DEFAULT_HANG_THRESHOLD = 5000; -/** - * A node.js watchdog timer - * @param pollInterval The interval that we expect to get polled at - * @param anrThreshold The threshold for when we consider ANR - * @param callback The callback to call for ANR - * @returns A function to call to reset the timer - */ -function watchdogTimer(pollInterval: number, anrThreshold: number, callback: () => void): () => void { - let lastPoll = process.hrtime(); - let triggered = false; - - setInterval(() => { - const [seconds, nanoSeconds] = process.hrtime(lastPoll); - const diffMs = Math.floor(seconds * 1e3 + nanoSeconds / 1e6); - - if (triggered === false && diffMs > pollInterval + anrThreshold) { - triggered = true; - callback(); - } - - if (diffMs < pollInterval + anrThreshold) { - triggered = false; - } - }, 20); - - return () => { - lastPoll = process.hrtime(); - }; -} - interface Options { /** * The app entry script. This is used to run the same script as the child process. @@ -216,10 +186,10 @@ function handleChildProcess(options: Options): void { } } - const ping = watchdogTimer(options.pollInterval, options.anrThreshold, watchdogTimeout); + const { poll } = watchdogTimer(options.pollInterval, options.anrThreshold, watchdogTimeout); process.on('message', () => { - ping(); + poll(); }); } diff --git a/packages/utils/src/anr.ts b/packages/utils/src/anr.ts new file mode 100644 index 000000000000..b007c1355cb7 --- /dev/null +++ b/packages/utils/src/anr.ts @@ -0,0 +1,133 @@ +import type { StackFrame } from '@sentry/types'; + +import { dropUndefinedKeys } from './object'; +import { filenameIsInApp, stripSentryFramesAndReverse } from './stacktrace'; + +type WatchdogReturn = { + /** Resets the watchdog timer */ + poll: () => void; + /** Enables or disables the watchdog timer */ + enabled: (state: boolean) => void; +}; + +/** + * A node.js watchdog timer + * @param pollInterval The interval that we expect to get polled at + * @param anrThreshold The threshold for when we consider ANR + * @param callback The callback to call for ANR + * @returns An object with `poll` and `enabled` functions {@link WatchdogReturn} + */ +export function watchdogTimer(pollInterval: number, anrThreshold: number, callback: () => void): WatchdogReturn { + let lastPoll = process.hrtime(); + let triggered = false; + let enabled = true; + + setInterval(() => { + const [seconds, nanoSeconds] = process.hrtime(lastPoll); + const diffMs = Math.floor(seconds * 1e3 + nanoSeconds / 1e6); + + if (triggered === false && diffMs > pollInterval + anrThreshold) { + triggered = true; + if (enabled) { + callback(); + } + } + + if (diffMs < pollInterval + anrThreshold) { + triggered = false; + } + }, 20); + + return { + poll: () => { + lastPoll = process.hrtime(); + }, + enabled: (state: boolean) => { + enabled = state; + }, + }; +} + +// types copied from inspector.d.ts +interface Location { + scriptId: string; + lineNumber: number; + columnNumber?: number; +} + +interface CallFrame { + functionName: string; + location: Location; + url: string; +} + +interface ScriptParsedEventDataType { + scriptId: string; + url: string; +} + +interface PausedEventDataType { + callFrames: CallFrame[]; + reason: string; +} + +/** + * Converts Debugger.CallFrame to Sentry StackFrame + */ +function callFrameToStackFrame( + frame: CallFrame, + url: string | undefined, + getModuleFromFilename: (filename: string | undefined) => string | undefined, +): StackFrame { + const filename = url ? url.replace(/^file:\/\//, '') : undefined; + + // CallFrame row/col are 0 based, whereas StackFrame are 1 based + const colno = frame.location.columnNumber ? frame.location.columnNumber + 1 : undefined; + const lineno = frame.location.lineNumber ? frame.location.lineNumber + 1 : undefined; + + return dropUndefinedKeys({ + filename, + module: getModuleFromFilename(filename), + function: frame.functionName || '?', + colno, + lineno, + 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/packages/utils/src/index.ts b/packages/utils/src/index.ts index 8de4941f6b96..81f4d947cd0d 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -31,3 +31,4 @@ export * from './url'; export * from './userIntegrations'; export * from './cache'; export * from './eventbuilder'; +export * from './anr';