Skip to content

feat(utils): Move common node ANR code to utils #9191

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';