Skip to content

Commit

Permalink
feat(utils): Move common node ANR code to utils (#9191)
Browse files Browse the repository at this point in the history
This PR moves common ANR code to utils so it can be used for Electron
renderer ANR detection.

## `watchdogTimer` 
Moved to utils with the addition of an enabled flag which isn't used in
node

## `createDebugPauseMessageHandler`
Handles messages from the debugger protocol.
- Collects script ids for later filename lookups
- Collects, converts and passes stack frames to a callback when the
debugger pauses
- Now uses `stripSentryFramesAndReverse` to remove Sentry frames!
  • Loading branch information
timfish authored Oct 11, 2023
1 parent bb67a11 commit 729e432
Show file tree
Hide file tree
Showing 4 changed files with 145 additions and 81 deletions.
56 changes: 8 additions & 48 deletions packages/node/src/anr/debugger.ts
Original file line number Diff line number Diff line change
@@ -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 =
| {
Expand All @@ -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);

Expand All @@ -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 }));
};
}

Expand All @@ -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<string, string>();

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');
Expand Down
36 changes: 3 additions & 33 deletions packages/node/src/anr/index.ts
Original file line number Diff line number Diff line change
@@ -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 '..';
Expand All @@ -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.
Expand Down Expand Up @@ -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();
});
}

Expand Down
133 changes: 133 additions & 0 deletions packages/utils/src/anr.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();

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);
}
};
}
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ export * from './url';
export * from './userIntegrations';
export * from './cache';
export * from './eventbuilder';
export * from './anr';

0 comments on commit 729e432

Please sign in to comment.