diff --git a/src/common/ipc.ts b/src/common/ipc.ts index 80934a0e..ac26f837 100644 --- a/src/common/ipc.ts +++ b/src/common/ipc.ts @@ -9,6 +9,34 @@ 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 { @@ -16,6 +44,7 @@ export interface IPCInterface { 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'; diff --git a/src/index.ts b/src/index.ts index 358c4638..2984eaa4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,7 +64,7 @@ interface ProcessEntryPoint { init: (options: Partial) => void; close?: (timeout?: number) => Promise; flush?: (timeout?: number) => Promise; - enableAnrDetection?(options: Parameters[0]): Promise; + enableMainProcessAnrDetection?(options: Parameters[0]): Promise; } /** Fetches the SDK entry point for the current process */ @@ -178,25 +178,25 @@ export async function flush(timeout?: number): Promise { * 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[0]): Promise { +export function enableMainProcessAnrDetection(options: Parameters[0]): Promise { 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'); diff --git a/src/main/anr.ts b/src/main/anr.ts index 6ed2738d..d74a70d4 100644 --- a/src/main/anr.ts +++ b/src/main/anr.ts @@ -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[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 { - 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[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> | 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'); + } + }; } /** @@ -36,24 +126,29 @@ function enableAnrMainProcess(options: MainProcessOptions): Promise { * 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 { - if (options.mainProcess !== false) { - return enableAnrMainProcess(options.mainProcess || {}); +export function enableMainProcessAnrDetection(options: Parameters[0]): Promise { + 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); } diff --git a/src/main/index.ts b/src/main/index.ts index 393df934..4f17eedf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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'; diff --git a/src/main/ipc.ts b/src/main/ipc.ts index cc8abda1..4d8bb460 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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'; @@ -168,6 +176,8 @@ function configureProtocol(options: ElectronMainOptionsInternal): void { }, ]); + const rendererStatusChanged = createRendererAnrStatusHook(); + whenAppReady .then(() => { for (const sesh of options.getSessions()) { @@ -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); + } } }); } @@ -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 */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 87c80cb7..78f91f99 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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__) { @@ -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 diff --git a/src/preload/legacy.ts b/src/preload/legacy.ts index 4fa233b7..9a9d4d15 100644 --- a/src/preload/legacy.ts +++ b/src/preload/legacy.ts @@ -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__) { @@ -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 diff --git a/src/renderer/anr.ts b/src/renderer/anr.ts new file mode 100644 index 00000000..cfe073c4 --- /dev/null +++ b/src/renderer/anr.ts @@ -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): 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); +} diff --git a/src/renderer/ipc.ts b/src/renderer/ipc.ts index f018a8dd..549874aa 100644 --- a/src/renderer/ipc.ts +++ b/src/renderer/ipc.ts @@ -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 @@ -47,6 +47,11 @@ function getImplementation(): IPCInterface { // ignore }); }, + sendStatus: (status: RendererStatus) => { + fetch(buildUrl(IPCChannel.STATUS), { method: 'POST', body: JSON.stringify({ status }), headers }).catch(() => { + // ignore + }); + }, }; } } diff --git a/src/renderer/sdk.ts b/src/renderer/sdk.ts index 131423c7..19e864bf 100644 --- a/src/renderer/sdk.ts +++ b/src/renderer/sdk.ts @@ -6,21 +6,39 @@ import { } from '@sentry/browser'; import { logger } from '@sentry/utils'; -import { ensureProcess } from '../common'; +import { ensureProcess, RendererProcessAnrOptions } from '../common'; +import { enableAnrRendererMessages } from './anr'; import { ScopeToMain } from './integrations'; import { electronRendererStackParser } from './stack-parse'; import { makeRendererTransport } from './transport'; export const defaultIntegrations = [...defaultBrowserIntegrations, new ScopeToMain()]; +interface ElectronRendererOptions extends BrowserOptions { + /** + * Enables ANR detection in this renderer process. + * + * Optionally accepts an object of options to configure ANR detection. + * + * { + * pollInterval: number; // Defaults to 1000ms + * anrThreshold: number; // Defaults to 5000ms + * captureStackTrace: boolean; // Defaults to false + * } + * + * Defaults to 'false'. + */ + anrDetection?: Partial | boolean; +} + /** * Initialize Sentry in the Electron renderer process * @param options SDK options * @param originalInit Optional init function for a specific framework SDK * @returns */ -export function init( - options: BrowserOptions & O = {} as BrowserOptions & O, +export function init( + options: ElectronRendererOptions & O = {} as ElectronRendererOptions & O, // This parameter name ensures that TypeScript error messages contain a hint for fixing SDK version mismatches originalInit: (if_you_get_a_typescript_error_ensure_sdks_use_version_v7_74_0: O) => void = browserInit, ): void { @@ -36,7 +54,7 @@ If init has been called in the preload and contextIsolation is disabled, is not window.__SENTRY__RENDERER_INIT__ = true; // We don't want browser session tracking enabled by default because we already have Electron - // specific session tracking + // specific session tracking from the main process. if (options.autoSessionTracking === undefined) { options.autoSessionTracking = false; } @@ -62,6 +80,10 @@ If init has been called in the preload and contextIsolation is disabled, is not options.transport = makeRendererTransport; } + if (options.anrDetection) { + enableAnrRendererMessages(options.anrDetection === true ? {} : options.anrDetection); + } + // We only handle initialScope in the main process otherwise it can cause race conditions over IPC delete options.initialScope; diff --git a/test/e2e/test-apps/anr/anr-main/src/main.js b/test/e2e/test-apps/anr/anr-main/src/main.js index d622faca..c733ffd7 100644 --- a/test/e2e/test-apps/anr/anr-main/src/main.js +++ b/test/e2e/test-apps/anr/anr-main/src/main.js @@ -1,7 +1,7 @@ const crypto = require('crypto'); const { app } = require('electron'); -const { init, enableAnrDetection } = require('@sentry/electron/main'); +const { init, enableMainProcessAnrDetection } = require('@sentry/electron/main'); init({ dsn: '__DSN__', @@ -18,8 +18,7 @@ function longWork() { } } -enableAnrDetection({ mainProcess: { debug: true, anrThreshold: 1000, captureStackTrace: true } }).then(() => { - console.log('main app code'); +enableMainProcessAnrDetection({ anrThreshold: 1000, captureStackTrace: true }).then(() => { app.on('ready', () => { setTimeout(() => { longWork(); diff --git a/test/e2e/test-apps/anr/anr-renderer/event.json b/test/e2e/test-apps/anr/anr-renderer/event.json new file mode 100644 index 00000000..2d99078d --- /dev/null +++ b/test/e2e/test-apps/anr/anr-renderer/event.json @@ -0,0 +1,93 @@ +{ + "method": "envelope", + "sentryKey": "37f8a2ee37c0409d8970bc7559c7c7e4", + "appId": "277345", + "data": { + "sdk": { + "name": "sentry.javascript.electron", + "packages": [ + { + "name": "npm:@sentry/electron", + "version": "{{version}}" + } + ], + "version": "{{version}}" + }, + "contexts": { + "app": { + "app_name": "anr-renderer", + "app_version": "1.0.0", + "app_start_time": "{{time}}" + }, + "browser": { + "name": "Chrome" + }, + "chrome": { + "name": "Chrome", + "type": "runtime", + "version": "{{version}}" + }, + "device": { + "arch": "{{arch}}", + "family": "Desktop", + "memory_size": 0, + "free_memory": 0, + "processor_count": 0, + "processor_frequency": 0, + "cpu_description": "{{cpu}}", + "screen_resolution": "{{screen}}", + "screen_density": 1, + "language": "{{language}}" + }, + "node": { + "name": "Node", + "type": "runtime", + "version": "{{version}}" + }, + "os": { + "name": "{{platform}}", + "version": "{{version}}" + }, + "runtime": { + "name": "Electron", + "version": "{{version}}" + } + }, + "release": "anr-renderer@1.0.0", + "environment": "development", + "user": { + "ip_address": "{{auto}}" + }, + "exception": { + "values": [ + { + "type": "ApplicationNotResponding", + "value": "Application Not Responding for at least 1000 ms", + "mechanism": { "type": "ANR" }, + "stacktrace": { + "frames": [ + { + "colno": 0, + "function": "{{function}}", + "in_app": false, + "lineno": 0, + "module": "pbkdf2" + } + ] + } + } + ] + }, + "level": "error", + "event_id": "{{id}}", + "platform": "node", + "timestamp": 0, + "breadcrumbs": [], + "tags": { + "event.environment": "javascript", + "event.origin": "electron", + "event.process": "renderer", + "event_type": "javascript" + } + } +} diff --git a/test/e2e/test-apps/anr/anr-renderer/package.json b/test/e2e/test-apps/anr/anr-renderer/package.json new file mode 100644 index 00000000..c31e4fc1 --- /dev/null +++ b/test/e2e/test-apps/anr/anr-renderer/package.json @@ -0,0 +1,8 @@ +{ + "name": "anr-renderer", + "version": "1.0.0", + "main": "src/main.js", + "dependencies": { + "@sentry/electron": "3.0.0" + } +} diff --git a/test/e2e/test-apps/anr/anr-renderer/recipe.yml b/test/e2e/test-apps/anr/anr-renderer/recipe.yml new file mode 100644 index 00000000..dc3ac921 --- /dev/null +++ b/test/e2e/test-apps/anr/anr-renderer/recipe.yml @@ -0,0 +1,4 @@ +description: ANR Renderer Event +category: ANR +command: yarn +condition: version.major >= 4 diff --git a/test/e2e/test-apps/anr/anr-renderer/src/index.html b/test/e2e/test-apps/anr/anr-renderer/src/index.html new file mode 100644 index 00000000..a160c47c --- /dev/null +++ b/test/e2e/test-apps/anr/anr-renderer/src/index.html @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/test/e2e/test-apps/anr/anr-renderer/src/main.js b/test/e2e/test-apps/anr/anr-renderer/src/main.js new file mode 100644 index 00000000..06a7431a --- /dev/null +++ b/test/e2e/test-apps/anr/anr-renderer/src/main.js @@ -0,0 +1,22 @@ +const path = require('path'); + +const { app, BrowserWindow } = require('electron'); +const { init } = require('@sentry/electron/main'); + +init({ + dsn: '__DSN__', + debug: true, + autoSessionTracking: false, + onFatalError: () => {}, +}); + +app.on('ready', () => { + const mainWindow = new BrowserWindow({ + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + }, + }); + + mainWindow.loadFile(path.join(__dirname, 'index.html')); +});