Skip to content
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

feat: Renderer process ANR detection with stack traces #770

Merged
merged 9 commits into from
Oct 17, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,20 @@
"e2e": "cross-env TS_NODE_PROJECT=tsconfig.test.json xvfb-maybe mocha --require ts-node/register/transpile-only --retries 3 ./test/e2e/*.ts"
},
"dependencies": {
"@sentry/browser": "7.73.0",
"@sentry/core": "7.73.0",
"@sentry/node": "7.73.0",
"@sentry/types": "7.73.0",
"@sentry/utils": "7.73.0",
"@sentry/browser": "7.74.0",
"@sentry/core": "7.74.0",
"@sentry/node": "7.74.0",
"@sentry/types": "7.74.0",
"@sentry/utils": "7.74.0",
"deepmerge": "4.3.0",
"lru_map": "^0.3.3",
"tslib": "^2.5.0"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.2.1",
"@rollup/plugin-typescript": "^11.1.4",
"@sentry-internal/eslint-config-sdk": "7.73.0",
"@sentry-internal/typescript": "7.73.0",
"@sentry-internal/eslint-config-sdk": "7.74.0",
"@sentry-internal/typescript": "7.74.0",
"@types/busboy": "^0.2.3",
"@types/chai": "^4.2.10",
"@types/chai-as-promised": "^7.1.5",
Expand Down
29 changes: 29 additions & 0 deletions src/common/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,42 @@ export enum IPCChannel {
SCOPE = 'sentry-electron.scope',
/** IPC to pass envelopes to the main process. */
ENVELOPE = 'sentry-electron.envelope',
/** IPC to pass renderer status updates */
STATUS = 'sentry-electron.status',
}

export interface RendererProcessAnrOptions {
/**
* Interval to send heartbeat messages to the child process.
*
* Defaults to 1000ms.
*/
pollInterval: number;
/**
* The number of milliseconds to wait before considering the renderer process to be unresponsive.
*
* Defaults to 5000ms.
*/
anrThreshold: number;
/**
* Whether to capture a stack trace when the renderer process is unresponsive.
*
* Defaults to `false`.
*/
captureStackTrace: boolean;
}

export interface RendererStatus {
status: 'alive' | 'visible' | 'hidden';
config: RendererProcessAnrOptions;
}

export interface IPCInterface {
sendRendererStart: () => void;
sendScope: (scope: string) => void;
sendEvent: (event: string) => void;
sendEnvelope: (evn: Uint8Array | string) => void;
sendStatus: (state: RendererStatus) => void;
}

export const RENDERER_ID_HEADER = 'sentry-electron-renderer-id';
Expand Down
14 changes: 7 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ interface ProcessEntryPoint {
init: (options: Partial<ElectronOptions>) => void;
close?: (timeout?: number) => Promise<boolean>;
flush?: (timeout?: number) => Promise<boolean>;
enableAnrDetection?(options: Parameters<typeof enableNodeAnrDetection>[0]): Promise<void>;
enableMainProcessAnrDetection?(options: Parameters<typeof enableNodeAnrDetection>[0]): Promise<void>;
}

/** Fetches the SDK entry point for the current process */
Expand Down Expand Up @@ -178,25 +178,25 @@ export async function flush(timeout?: number): Promise<boolean> {
* child process.
*
* ```js
* import { init, enableAnrDetection } from '@sentry/electron';
* import { init, enableMainProcessAnrDetection } from '@sentry/electron';
*
* init({ dsn: "__DSN__" });
*
* // with ESM + Electron v28+
* await enableAnrDetection({ captureStackTrace: true });
* await enableMainProcessAnrDetection({ captureStackTrace: true });
* runApp();
*
* // with CJS
* enableAnrDetection({ captureStackTrace: true }).then(() => {
* enableMainProcessAnrDetection({ captureStackTrace: true }).then(() => {
* runApp();
* });
* ```
*/
export async function enableAnrDetection(options: Parameters<typeof enableNodeAnrDetection>[0]): Promise<void> {
export function enableMainProcessAnrDetection(options: Parameters<typeof enableNodeAnrDetection>[0]): Promise<void> {
const entryPoint = getEntryPoint();

if (entryPoint.enableAnrDetection) {
return entryPoint.enableAnrDetection(options);
if (entryPoint.enableMainProcessAnrDetection) {
return entryPoint.enableMainProcessAnrDetection(options);
}

throw new Error('ANR detection should be started in the main process');
Expand Down
145 changes: 120 additions & 25 deletions src/main/anr.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,120 @@
import { enableAnrDetection as enableNodeAnrDetection } from '@sentry/node';
import { app } from 'electron';
import {
captureEvent,
enableAnrDetection as enableNodeAnrDetection,
getCurrentHub,
getModuleFromFilename,
StackFrame,
} from '@sentry/node';
import { Event } from '@sentry/types';
import { createDebugPauseMessageHandler, logger, watchdogTimer } from '@sentry/utils';
import { app, WebContents } from 'electron';

import { RendererStatus } from '../common';
import { ELECTRON_MAJOR_VERSION } from './electron-normalize';
import { ElectronMainOptions } from './sdk';

type MainProcessOptions = Parameters<typeof enableNodeAnrDetection>[0];
function getRendererName(contents: WebContents): string | undefined {
const options = getCurrentHub().getClient()?.getOptions() as ElectronMainOptions | undefined;
return options?.getRendererName?.(contents);
}

function sendRendererAnrEvent(contents: WebContents, blockedMs: number, frames?: StackFrame[]): void {
const rendererName = getRendererName(contents) || 'renderer';

interface Options {
/**
* Main process ANR options.
*
* Set to false to disable ANR detection in the main process.
*/
mainProcess?: MainProcessOptions | false;
const event: Event = {
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',
},
},
],
},
tags: {
'event.process': rendererName,
},
};

captureEvent(event);
}

function enableAnrMainProcess(options: MainProcessOptions): Promise<void> {
if (ELECTRON_MAJOR_VERSION < 4) {
throw new Error('Main process ANR detection is only supported on Electron v4+');
}
function rendererDebugger(contents: WebContents, pausedStack: (frames: StackFrame[]) => void): () => void {
contents.debugger.attach('1.3');

const mainOptions = {
entryScript: app.getAppPath(),
...options,
const messageHandler = createDebugPauseMessageHandler(
(cmd) => contents.debugger.sendCommand(cmd),
getModuleFromFilename,
pausedStack,
);

contents.debugger.on('message', (_, method, params) => {
messageHandler({ method, params } as Parameters<typeof messageHandler>[0]);
});

// In node, we enable just before pausing but for Chrome, the debugger must be enabled before he ANR event occurs
void contents.debugger.sendCommand('Debugger.enable');

return () => {
return contents.debugger.sendCommand('Debugger.pause');
};
}

return enableNodeAnrDetection(mainOptions);
let rendererWatchdogTimers: Map<WebContents, ReturnType<typeof watchdogTimer>> | undefined;

/** Creates a renderer ANR status hook */
export function createRendererAnrStatusHook(): (status: RendererStatus, contents: WebContents) => void {
function log(message: string, ...args: unknown[]): void {
logger.log(`[Renderer ANR] ${message}`, ...args);
}

return (message: RendererStatus, contents: WebContents): void => {
rendererWatchdogTimers = rendererWatchdogTimers || new Map();

let watchdog = rendererWatchdogTimers.get(contents);

if (watchdog === undefined) {
log('Renderer sent first status message', message.config);
let pauseAndCapture: (() => void) | undefined;

if (message.config.captureStackTrace) {
log('Connecting to debugger');
pauseAndCapture = rendererDebugger(contents, (frames) => {
log('Event captured with stack frames');
sendRendererAnrEvent(contents, message.config.anrThreshold, frames);
});
}

watchdog = watchdogTimer(100, message.config.anrThreshold, async () => {
log('Watchdog timeout');
if (pauseAndCapture) {
log('Pausing debugger to capture stack trace');
pauseAndCapture();
} else {
log('Capturing event');
sendRendererAnrEvent(contents, message.config.anrThreshold);
}
});

contents.once('destroyed', () => {
rendererWatchdogTimers?.delete(contents);
});

rendererWatchdogTimers.set(contents, watchdog);
}

watchdog.poll();

if (message.status !== 'alive') {
log('Renderer visibility changed', message.status);
watchdog.enabled(message.status === 'visible');
}
};
}

/**
Expand All @@ -36,24 +126,29 @@ function enableAnrMainProcess(options: MainProcessOptions): Promise<void> {
* child process.
*
* ```js
* import { init, enableAnrDetection } from '@sentry/electron';
* import { init, enableMainProcessAnrDetection } from '@sentry/electron';
*
* init({ dsn: "__DSN__" });
*
* // with ESM + Electron v28+
* await enableAnrDetection({ mainProcess: { captureStackTrace: true }});
* await enableMainProcessAnrDetection({ captureStackTrace: true });
* runApp();
*
* // with CJS
* enableAnrDetection({ mainProcess: { captureStackTrace: true }}).then(() => {
* enableMainProcessAnrDetection({ captureStackTrace: true }).then(() => {
* runApp();
* });
* ```
*/
export async function enableAnrDetection(options: Options = {}): Promise<void> {
if (options.mainProcess !== false) {
return enableAnrMainProcess(options.mainProcess || {});
export function enableMainProcessAnrDetection(options: Parameters<typeof enableNodeAnrDetection>[0]): Promise<void> {
if (ELECTRON_MAJOR_VERSION < 4) {
throw new Error('Main process ANR detection is only supported on Electron v4+');
}

return Promise.resolve();
const mainOptions = {
entryScript: app.getAppPath(),
...options,
};

return enableNodeAnrDetection(mainOptions);
}
2 changes: 1 addition & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,4 @@ export const Integrations = { ...ElectronMainIntegrations, ...NodeIntegrations }
export type { ElectronMainOptions } from './sdk';
export { init, defaultIntegrations } from './sdk';
export { IPCMode } from '../common';
export { enableAnrDetection } from './anr';
export { enableMainProcessAnrDetection } from './anr';
21 changes: 20 additions & 1 deletion src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ import { forEachEnvelopeItem, logger, parseEnvelope, SentryError } from '@sentry
import { app, ipcMain, protocol, WebContents, webContents } from 'electron';
import { TextDecoder, TextEncoder } from 'util';

import { IPCChannel, IPCMode, mergeEvents, normalizeUrlsInReplayEnvelope, PROTOCOL_SCHEME } from '../common';
import {
IPCChannel,
IPCMode,
mergeEvents,
normalizeUrlsInReplayEnvelope,
PROTOCOL_SCHEME,
RendererStatus,
} from '../common';
import { createRendererAnrStatusHook } from './anr';
import { registerProtocol, supportsFullProtocol, whenAppReady } from './electron-normalize';
import { ElectronMainOptionsInternal } from './sdk';

Expand Down Expand Up @@ -168,6 +176,8 @@ function configureProtocol(options: ElectronMainOptionsInternal): void {
},
]);

const rendererStatusChanged = createRendererAnrStatusHook();

whenAppReady
.then(() => {
for (const sesh of options.getSessions()) {
Expand All @@ -186,6 +196,12 @@ function configureProtocol(options: ElectronMainOptionsInternal): void {
handleScope(options, data.toString());
} else if (request.url.startsWith(`${PROTOCOL_SCHEME}://${IPCChannel.ENVELOPE}`) && data) {
handleEnvelope(options, data, getWebContents());
} else if (request.url.startsWith(`${PROTOCOL_SCHEME}://${IPCChannel.STATUS}`) && data) {
const contents = getWebContents();
if (contents) {
const status = (JSON.parse(data.toString()) as { status: RendererStatus }).status;
rendererStatusChanged(status, contents);
}
}
});
}
Expand Down Expand Up @@ -214,6 +230,9 @@ function configureClassic(options: ElectronMainOptionsInternal): void {
ipcMain.on(IPCChannel.EVENT, ({ sender }, jsonEvent: string) => handleEvent(options, jsonEvent, sender));
ipcMain.on(IPCChannel.SCOPE, (_, jsonScope: string) => handleScope(options, jsonScope));
ipcMain.on(IPCChannel.ENVELOPE, ({ sender }, env: Uint8Array | string) => handleEnvelope(options, env, sender));

const rendererStatusChanged = createRendererAnrStatusHook();
ipcMain.on(IPCChannel.STATUS, ({ sender }, status: RendererStatus) => rendererStatusChanged(status, sender));
}

/** Sets up communication channels with the renderer */
Expand Down
3 changes: 2 additions & 1 deletion src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import { contextBridge, ipcRenderer } from 'electron';

import { IPCChannel } from '../common/ipc';
import { IPCChannel, RendererStatus } from '../common/ipc';

// eslint-disable-next-line no-restricted-globals
if (window.__SENTRY_IPC__) {
Expand All @@ -16,6 +16,7 @@ if (window.__SENTRY_IPC__) {
sendScope: (scopeJson: string) => ipcRenderer.send(IPCChannel.SCOPE, scopeJson),
sendEvent: (eventJson: string) => ipcRenderer.send(IPCChannel.EVENT, eventJson),
sendEnvelope: (envelope: Uint8Array | string) => ipcRenderer.send(IPCChannel.ENVELOPE, envelope),
sendStatus: (status: RendererStatus) => ipcRenderer.send(IPCChannel.STATUS, status),
};

// eslint-disable-next-line no-restricted-globals
Expand Down
3 changes: 2 additions & 1 deletion src/preload/legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { contextBridge, crashReporter, ipcRenderer } from 'electron';
import * as electron from 'electron';

import { IPCChannel } from '../common/ipc';
import { IPCChannel, RendererStatus } from '../common/ipc';

// eslint-disable-next-line no-restricted-globals
if (window.__SENTRY_IPC__) {
Expand All @@ -27,6 +27,7 @@ if (window.__SENTRY_IPC__) {
sendScope: (scopeJson: string) => ipcRenderer.send(IPCChannel.SCOPE, scopeJson),
sendEvent: (eventJson: string) => ipcRenderer.send(IPCChannel.EVENT, eventJson),
sendEnvelope: (envelope: Uint8Array | string) => ipcRenderer.send(IPCChannel.ENVELOPE, envelope),
sendStatus: (status: RendererStatus) => ipcRenderer.send(IPCChannel.STATUS, status),
};

// eslint-disable-next-line no-restricted-globals
Expand Down