Skip to content

Commit

Permalink
feat: Renderer process ANR detection with stack traces (#770)
Browse files Browse the repository at this point in the history
  • Loading branch information
timfish authored Oct 17, 2023
1 parent d5279b2 commit 2bf8a54
Show file tree
Hide file tree
Showing 16 changed files with 398 additions and 44 deletions.
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
27 changes: 27 additions & 0 deletions src/renderer/anr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/* eslint-disable no-restricted-globals */
import { RendererProcessAnrOptions } from '../common/ipc';
import { getIPC } from './ipc';

/**
* Enables the sending of ANR messages to the main process.
*/
export function enableAnrRendererMessages(options: Partial<RendererProcessAnrOptions>): void {
const config: RendererProcessAnrOptions = {
pollInterval: 1_000,
anrThreshold: 5_000,
captureStackTrace: false,
...options,
};

const ipc = getIPC();

document.addEventListener('visibilitychange', () => {
ipc.sendStatus({ status: document.visibilityState, config });
});

ipc.sendStatus({ status: document.visibilityState, config });

setInterval(() => {
ipc.sendStatus({ status: 'alive', config });
}, config.pollInterval);
}
7 changes: 6 additions & 1 deletion src/renderer/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/* eslint-disable no-console */
import { logger, uuid4 } from '@sentry/utils';

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

function buildUrl(channel: IPCChannel): string {
// We include sentry_key in the URL so these don't end up in fetch breadcrumbs
Expand Down Expand Up @@ -47,6 +47,11 @@ function getImplementation(): IPCInterface {
// ignore
});
},
sendStatus: (status: RendererStatus) => {
fetch(buildUrl(IPCChannel.STATUS), { method: 'POST', body: JSON.stringify({ status }), headers }).catch(() => {
// ignore
});
},
};
}
}
Expand Down
Loading

0 comments on commit 2bf8a54

Please sign in to comment.