diff --git a/src/index.ts b/src/index.ts index 28e105b8..358c4638 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,8 @@ export { trace, } from '@sentry/core'; +import type { enableAnrDetection as enableNodeAnrDetection } from '@sentry/node'; + export const Integrations = getIntegrations(); // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -62,6 +64,7 @@ interface ProcessEntryPoint { init: (options: Partial) => void; close?: (timeout?: number) => Promise; flush?: (timeout?: number) => Promise; + enableAnrDetection?(options: Parameters[0]): Promise; } /** Fetches the SDK entry point for the current process */ @@ -165,3 +168,36 @@ export async function flush(timeout?: number): Promise { throw new Error('The Electron SDK should be flushed from the main process'); } + +/** + * **Note** This feature is still in beta so there may be breaking changes in future releases. + * + * Starts a child process that detects Application Not Responding (ANR) errors. + * + * It's important to await on the returned promise before your app code to ensure this code does not run in the ANR + * child process. + * + * ```js + * import { init, enableAnrDetection } from '@sentry/electron'; + * + * init({ dsn: "__DSN__" }); + * + * // with ESM + Electron v28+ + * await enableAnrDetection({ captureStackTrace: true }); + * runApp(); + * + * // with CJS + * enableAnrDetection({ captureStackTrace: true }).then(() => { + * runApp(); + * }); + * ``` + */ +export async function enableAnrDetection(options: Parameters[0]): Promise { + const entryPoint = getEntryPoint(); + + if (entryPoint.enableAnrDetection) { + return entryPoint.enableAnrDetection(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 new file mode 100644 index 00000000..6ed2738d --- /dev/null +++ b/src/main/anr.ts @@ -0,0 +1,59 @@ +import { enableAnrDetection as enableNodeAnrDetection } from '@sentry/node'; +import { app } from 'electron'; + +import { ELECTRON_MAJOR_VERSION } from './electron-normalize'; + +type MainProcessOptions = Parameters[0]; + +interface Options { + /** + * Main process ANR options. + * + * Set to false to disable ANR detection in the main process. + */ + mainProcess?: MainProcessOptions | false; +} + +function enableAnrMainProcess(options: MainProcessOptions): Promise { + if (ELECTRON_MAJOR_VERSION < 4) { + throw new Error('Main process ANR detection is only supported on Electron v4+'); + } + + const mainOptions = { + entryScript: app.getAppPath(), + ...options, + }; + + return enableNodeAnrDetection(mainOptions); +} + +/** + * **Note** This feature is still in beta so there may be breaking changes in future releases. + * + * Starts a child process that detects Application Not Responding (ANR) errors. + * + * It's important to await on the returned promise before your app code to ensure this code does not run in the ANR + * child process. + * + * ```js + * import { init, enableAnrDetection } from '@sentry/electron'; + * + * init({ dsn: "__DSN__" }); + * + * // with ESM + Electron v28+ + * await enableAnrDetection({ mainProcess: { captureStackTrace: true }}); + * runApp(); + * + * // with CJS + * enableAnrDetection({ mainProcess: { captureStackTrace: true }}).then(() => { + * runApp(); + * }); + * ``` + */ +export async function enableAnrDetection(options: Options = {}): Promise { + if (options.mainProcess !== false) { + return enableAnrMainProcess(options.mainProcess || {}); + } + + return Promise.resolve(); +} diff --git a/src/main/index.ts b/src/main/index.ts index 7a566c4d..393df934 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -58,3 +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'; diff --git a/src/main/sdk.ts b/src/main/sdk.ts index 94b645d9..68f48559 100644 --- a/src/main/sdk.ts +++ b/src/main/sdk.ts @@ -96,6 +96,11 @@ export function init(userOptions: ElectronMainOptions): void { const options: ElectronMainOptionsInternal = Object.assign(defaultOptions, userOptions); const defaults = defaultIntegrations; + if (process.env.SENTRY_ANR_CHILD_PROCESS) { + options.autoSessionTracking = false; + options.tracesSampleRate = 0; + } + // If we don't set a release, @sentry/node will automatically fetch from environment variables if (options.release === undefined) { options.release = getDefaultReleaseName(); diff --git a/test/e2e/test-apps/anr/anr-main/event.json b/test/e2e/test-apps/anr/anr-main/event.json new file mode 100644 index 00000000..58c95e39 --- /dev/null +++ b/test/e2e/test-apps/anr/anr-main/event.json @@ -0,0 +1,94 @@ +{ + "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-main", + "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-main@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": "browser", + "event_type": "javascript", + "process.name": "ANR" + } + } +} diff --git a/test/e2e/test-apps/anr/anr-main/package.json b/test/e2e/test-apps/anr/anr-main/package.json new file mode 100644 index 00000000..7fc3834f --- /dev/null +++ b/test/e2e/test-apps/anr/anr-main/package.json @@ -0,0 +1,8 @@ +{ + "name": "anr-main", + "version": "1.0.0", + "main": "src/main.js", + "dependencies": { + "@sentry/electron": "3.0.0" + } +} diff --git a/test/e2e/test-apps/anr/anr-main/recipe.yml b/test/e2e/test-apps/anr/anr-main/recipe.yml new file mode 100644 index 00000000..d65df596 --- /dev/null +++ b/test/e2e/test-apps/anr/anr-main/recipe.yml @@ -0,0 +1,4 @@ +description: ANR Main Event +category: ANR +command: yarn +condition: version.major >= 4 diff --git a/test/e2e/test-apps/anr/anr-main/src/main.js b/test/e2e/test-apps/anr/anr-main/src/main.js new file mode 100644 index 00000000..d622faca --- /dev/null +++ b/test/e2e/test-apps/anr/anr-main/src/main.js @@ -0,0 +1,28 @@ +const crypto = require('crypto'); + +const { app } = require('electron'); +const { init, enableAnrDetection } = require('@sentry/electron/main'); + +init({ + dsn: '__DSN__', + debug: true, + autoSessionTracking: false, + onFatalError: () => {}, +}); + +function longWork() { + for (let i = 0; i < 100; i++) { + const salt = crypto.randomBytes(128).toString('base64'); + // eslint-disable-next-line no-unused-vars + const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); + } +} + +enableAnrDetection({ mainProcess: { debug: true, anrThreshold: 1000, captureStackTrace: true } }).then(() => { + console.log('main app code'); + app.on('ready', () => { + setTimeout(() => { + longWork(); + }, 1000); + }); +});