diff --git a/src/main/electron-normalize.ts b/src/main/electron-normalize.ts index cbdf4d39..e4f698c7 100644 --- a/src/main/electron-normalize.ts +++ b/src/main/electron-normalize.ts @@ -7,6 +7,8 @@ import { Optional } from '../common/types'; const parsed = parseSemver(process.versions.electron); const version = { major: parsed.major || 0, minor: parsed.minor || 0, patch: parsed.patch || 0 }; +export const ELECTRON_MAJOR_VERSION = version.major; + /** Returns if the app is packaged. Copied from Electron to support < v3 */ export const isPackaged = (() => { const execFile = basename(process.execPath).toLowerCase(); diff --git a/src/main/integrations/browser-window-session.ts b/src/main/integrations/browser-window-session.ts new file mode 100644 index 00000000..142cdf45 --- /dev/null +++ b/src/main/integrations/browser-window-session.ts @@ -0,0 +1,96 @@ +import { Integration } from '@sentry/types'; +import { app, BrowserWindow } from 'electron'; + +import { ELECTRON_MAJOR_VERSION } from '../electron-normalize'; +import { endSession, endSessionOnExit, startSession } from '../sessions'; + +interface Options { + /** + * Number of seconds to wait before ending a session after the app loses focus. + * + * Default: 10 seconds + */ + backgroundTimeoutSeconds?: number; +} + +// The state can be, active, inactive, or waiting for a timeout +type SessionState = { name: 'active' } | { name: 'inactive' } | { name: 'timeout'; timer: NodeJS.Timeout }; + +/** + * Tracks sessions as BrowserWindows focused. + * + * Supports Electron >= v12 + */ +export class BrowserWindowSession implements Integration { + /** @inheritDoc */ + public static id: string = 'BrowserWindowSession'; + + /** @inheritDoc */ + public readonly name: string; + + private _state: SessionState; + + public constructor(private readonly _options: Options = {}) { + if (ELECTRON_MAJOR_VERSION < 12) { + throw new Error('BrowserWindowSession requires Electron >= v12'); + } + + this.name = BrowserWindowSession.id; + this._state = { name: 'inactive' }; + } + + /** @inheritDoc */ + public setupOnce(): void { + app.on('browser-window-created', (_event, window) => { + window.on('focus', this._windowStateChanged); + window.on('blur', this._windowStateChanged); + window.on('show', this._windowStateChanged); + window.on('hide', this._windowStateChanged); + + // when the window is closed we need to remove the listeners + window.once('closed', () => { + window.removeListener('focus', this._windowStateChanged); + window.removeListener('blur', this._windowStateChanged); + window.removeListener('show', this._windowStateChanged); + window.removeListener('hide', this._windowStateChanged); + }); + }); + + // if the app exits while the session is active, end the session + endSessionOnExit(); + } + + private _windowStateChanged = (): void => { + const aWindowIsActive = !!BrowserWindow.getFocusedWindow(); + + if (aWindowIsActive) { + // We are now active + if (this._state.name === 'inactive') { + // If we were inactive, start a new session + void startSession(true); + } else if (this._state.name === 'timeout') { + // Clear the timeout since the app has become active again + clearTimeout(this._state.timer); + } + + this._state = { name: 'active' }; + } else { + if (this._state.name === 'active') { + // We have become inactive, start the timeout + const timeout = (this._options.backgroundTimeoutSeconds ?? 30) * 1_000; + + const timer = setTimeout(() => { + // if the state says we're still waiting for the timeout, end the session + if (this._state.name === 'timeout') { + this._state = { name: 'inactive' }; + void endSession(); + } + }, timeout) + // unref so this timer doesn't block app exit + .unref(); + + this._state = { name: 'timeout', timer }; + } + } + }; +} diff --git a/src/main/integrations/index.ts b/src/main/integrations/index.ts index 313bb41f..e723f064 100644 --- a/src/main/integrations/index.ts +++ b/src/main/integrations/index.ts @@ -5,6 +5,7 @@ export { SentryMinidump } from './sentry-minidump'; export { ElectronMinidump } from './electron-minidump'; export { PreloadInjection } from './preload-injection'; export { MainProcessSession } from './main-process-session'; +export { BrowserWindowSession } from './browser-window-session'; export { AdditionalContext } from './additional-context'; export { Net } from './net-breadcrumbs'; export { ChildProcess } from './child-process'; diff --git a/src/main/integrations/main-process-session.ts b/src/main/integrations/main-process-session.ts index 983900d3..3d161681 100644 --- a/src/main/integrations/main-process-session.ts +++ b/src/main/integrations/main-process-session.ts @@ -1,8 +1,6 @@ import { Integration } from '@sentry/types'; -import { logger } from '@sentry/utils'; -import { app } from 'electron'; -import { endSession, startSession } from '../sessions'; +import { endSessionOnExit, startSession } from '../sessions'; interface Options { /** @@ -29,46 +27,6 @@ export class MainProcessSession implements Integration { public setupOnce(): void { void startSession(!!this._options.sendOnCreate); - // We track sessions via the 'will-quit' event which is the last event emitted before close. - // - // We need to be the last 'will-quit' listener so as not to interfere with any user defined listeners which may - // call `event.preventDefault()`. - this._ensureExitHandlerLast(); - - // 'before-quit' is always called before 'will-quit' so we listen there and ensure our 'will-quit' handler is still - // the last listener - app.on('before-quit', () => { - this._ensureExitHandlerLast(); - }); - } - - /** - * Hooks 'will-quit' and ensures the handler is always last - */ - private _ensureExitHandlerLast(): void { - app.removeListener('will-quit', this._exitHandler); - app.on('will-quit', this._exitHandler); + endSessionOnExit(); } - - /** Handles the exit */ - private _exitHandler: (event: Electron.Event) => Promise = async (event: Electron.Event) => { - if (event.defaultPrevented) { - return; - } - - logger.log('[MainProcessSession] Exit Handler'); - - // Stop the exit so we have time to send the session - event.preventDefault(); - - try { - // End the session - await endSession(); - } catch (e) { - // Ignore and log any errors which would prevent app exit - logger.warn('[MainProcessSession] Error ending session:', e); - } - - app.exit(); - }; } diff --git a/src/main/sdk.ts b/src/main/sdk.ts index 80e84d07..94b645d9 100644 --- a/src/main/sdk.ts +++ b/src/main/sdk.ts @@ -128,21 +128,32 @@ export function init(userOptions: ElectronMainOptions): void { nodeInit(options); } -/** Sets the default integrations and ensures that multiple minidump integrations are not enabled */ +/** A list of integrations which cause default integrations to be removed */ +const INTEGRATION_OVERRIDES = [ + { override: 'ElectronMinidump', remove: 'SentryMinidump' }, + { override: 'BrowserWindowSession', remove: 'MainProcessSession' }, +]; + +/** Sets the default integrations and ensures that multiple minidump or session integrations are not enabled */ function setDefaultIntegrations(defaults: Integration[], options: ElectronMainOptions): void { if (options.defaultIntegrations === undefined) { - // If ElectronMinidump has been included, automatically remove SentryMinidump - if (Array.isArray(options.integrations) && options.integrations.some((i) => i.name === 'ElectronMinidump')) { - options.defaultIntegrations = defaults.filter((integration) => integration.name !== 'SentryMinidump'); + const removeDefaultsMatching = (user: Integration[], defaults: Integration[]): Integration[] => { + const toRemove = INTEGRATION_OVERRIDES.filter(({ override }) => user.some((i) => i.name === override)).map( + ({ remove }) => remove, + ); + + return defaults.filter((i) => !toRemove.includes(i.name)); + }; + + if (Array.isArray(options.integrations)) { + options.defaultIntegrations = removeDefaultsMatching(options.integrations, defaults); return; } else if (typeof options.integrations === 'function') { const originalFn = options.integrations; options.integrations = (integrations) => { - const userIntegrations = originalFn(integrations); - return userIntegrations.some((i) => i.name === 'ElectronMinidump') - ? userIntegrations.filter((integration) => integration.name !== 'SentryMinidump') - : userIntegrations; + const resultIntegrations = originalFn(integrations); + return removeDefaultsMatching(resultIntegrations, resultIntegrations); }; } diff --git a/src/main/sessions.ts b/src/main/sessions.ts index f970b2db..080a8522 100644 --- a/src/main/sessions.ts +++ b/src/main/sessions.ts @@ -2,6 +2,7 @@ import { getCurrentHub, makeSession, updateSession } from '@sentry/core'; import { flush, NodeClient } from '@sentry/node'; import { SerializedSession, Session, SessionContext, SessionStatus } from '@sentry/types'; import { logger } from '@sentry/utils'; +import { app } from 'electron'; import { sentryCachePath } from './fs'; import { Store } from './store'; @@ -11,7 +12,7 @@ const PERSIST_INTERVAL_MS = 60_000; /** Stores the app session in case of termination due to main process crash or app killed */ const sessionStore = new Store(sentryCachePath, 'session', undefined); -/** Previous session that did not exit cleanly */ +/** Previous session if it did not exit cleanly */ let previousSession: Promise | undefined> | undefined = sessionStore.get(); let persistTimer: NodeJS.Timer | undefined; @@ -145,3 +146,41 @@ export function sessionCrashed(): void { hub.captureSession(); } + +/** + * End the current session on app exit + */ +export function endSessionOnExit(): void { + // 'before-quit' is always called before 'will-quit' so we listen there and ensure our 'will-quit' handler is still + // the last listener + app.on('before-quit', () => { + // We track the end of sessions via the 'will-quit' event which is the last event emitted before close. + // + // We need to be the last 'will-quit' listener so as not to interfere with any user defined listeners which may + // call `event.preventDefault()` to abort the exit. + app.removeListener('will-quit', exitHandler); + app.on('will-quit', exitHandler); + }); +} + +/** Handles the exit */ +const exitHandler: (event: Electron.Event) => Promise = async (event: Electron.Event) => { + if (event.defaultPrevented) { + return; + } + + logger.log('[Session] Exit Handler'); + + // Stop the exit so we have time to send the session + event.preventDefault(); + + try { + // End the session + await endSession(); + } catch (e) { + // Ignore and log any errors which would prevent app exit + logger.warn('[Session] Error ending session:', e); + } + + app.exit(); +}; diff --git a/test/e2e/test-apps/sessions/window-bad/event.json b/test/e2e/test-apps/sessions/window-bad/event.json new file mode 100644 index 00000000..1d07c6b5 --- /dev/null +++ b/test/e2e/test-apps/sessions/window-bad/event.json @@ -0,0 +1,99 @@ +{ + "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": "bad-session-window", + "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": "bad-session-window@1.0.0", + "environment": "development", + "user": { + "ip_address": "{{auto}}" + }, + "exception": { + "values": [ + { + "type": "Error", + "value": "Some renderer error", + "stacktrace": { + "frames": [ + { + "colno": 0, + "filename": "app:///src/index.html", + "function": "{{function}}", + "in_app": true, + "lineno": 0 + } + ] + }, + "mechanism": { + "handled": true, + "type": "instrument" + } + } + ] + }, + "level": "error", + "event_id": "{{id}}", + "platform": "javascript", + "timestamp": 0, + "breadcrumbs": [], + "request": { + "url": "app:///src/index.html" + }, + "tags": { + "event.environment": "javascript", + "event.origin": "electron", + "event.process": "renderer", + "event_type": "javascript" + } + } +} diff --git a/test/e2e/test-apps/sessions/window-bad/package.json b/test/e2e/test-apps/sessions/window-bad/package.json new file mode 100644 index 00000000..8dce09ae --- /dev/null +++ b/test/e2e/test-apps/sessions/window-bad/package.json @@ -0,0 +1,8 @@ +{ + "name": "bad-session-window", + "version": "1.0.0", + "main": "src/main.js", + "dependencies": { + "@sentry/electron": "3.0.0" + } +} diff --git a/test/e2e/test-apps/sessions/window-bad/recipe.yml b/test/e2e/test-apps/sessions/window-bad/recipe.yml new file mode 100644 index 00000000..990c6571 --- /dev/null +++ b/test/e2e/test-apps/sessions/window-bad/recipe.yml @@ -0,0 +1,4 @@ +description: Bad Session - Window +category: Sessions +command: yarn +condition: version.major >= 12 diff --git a/test/e2e/test-apps/sessions/window-bad/session-first.json b/test/e2e/test-apps/sessions/window-bad/session-first.json new file mode 100644 index 00000000..83434cfa --- /dev/null +++ b/test/e2e/test-apps/sessions/window-bad/session-first.json @@ -0,0 +1,17 @@ +{ + "appId": "277345", + "sentryKey": "37f8a2ee37c0409d8970bc7559c7c7e4", + "method": "envelope", + "data": { + "sid": "{{id}}", + "init": true, + "started": 0, + "timestamp": 0, + "status": "ok", + "errors": 0, + "duration": 0, + "attrs": { + "release": "bad-session-window@1.0.0" + } + } +} diff --git a/test/e2e/test-apps/sessions/window-bad/session-second.json b/test/e2e/test-apps/sessions/window-bad/session-second.json new file mode 100644 index 00000000..ca4ea31d --- /dev/null +++ b/test/e2e/test-apps/sessions/window-bad/session-second.json @@ -0,0 +1,17 @@ +{ + "appId": "277345", + "sentryKey": "37f8a2ee37c0409d8970bc7559c7c7e4", + "method": "envelope", + "data": { + "sid": "{{id}}", + "init": false, + "started": 0, + "timestamp": 0, + "status": "ok", + "errors": 1, + "duration": 0, + "attrs": { + "release": "bad-session-window@1.0.0" + } + } +} diff --git a/test/e2e/test-apps/sessions/window-bad/session-third.json b/test/e2e/test-apps/sessions/window-bad/session-third.json new file mode 100644 index 00000000..11b6d849 --- /dev/null +++ b/test/e2e/test-apps/sessions/window-bad/session-third.json @@ -0,0 +1,17 @@ +{ + "appId": "277345", + "sentryKey": "37f8a2ee37c0409d8970bc7559c7c7e4", + "method": "envelope", + "data": { + "sid": "{{id}}", + "init": false, + "started": 0, + "timestamp": 0, + "status": "exited", + "errors": 1, + "duration": 0, + "attrs": { + "release": "bad-session-window@1.0.0" + } + } +} diff --git a/test/e2e/test-apps/sessions/window-bad/src/index.html b/test/e2e/test-apps/sessions/window-bad/src/index.html new file mode 100644 index 00000000..c30b5ca8 --- /dev/null +++ b/test/e2e/test-apps/sessions/window-bad/src/index.html @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/test/e2e/test-apps/sessions/window-bad/src/main.js b/test/e2e/test-apps/sessions/window-bad/src/main.js new file mode 100644 index 00000000..c542d786 --- /dev/null +++ b/test/e2e/test-apps/sessions/window-bad/src/main.js @@ -0,0 +1,27 @@ +const path = require('path'); + +const { app, BrowserWindow } = require('electron'); +const { init, Integrations } = require('@sentry/electron'); + +init({ + dsn: '__DSN__', + debug: true, + integrations: [new Integrations.BrowserWindowSession({ backgroundTimeoutSeconds: 1 })], + onFatalError: () => {}, +}); + +app.on('ready', () => { + const mainWindow = new BrowserWindow({ + show: true, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + }, + }); + + mainWindow.loadFile(path.join(__dirname, 'index.html')); + + setTimeout(() => { + app.quit(); + }, 2000); +}); diff --git a/test/e2e/test-apps/sessions/window-good/package.json b/test/e2e/test-apps/sessions/window-good/package.json new file mode 100644 index 00000000..bdabd15f --- /dev/null +++ b/test/e2e/test-apps/sessions/window-good/package.json @@ -0,0 +1,8 @@ +{ + "name": "good-session-window", + "version": "1.0.0", + "main": "src/main.js", + "dependencies": { + "@sentry/electron": "3.0.0" + } +} diff --git a/test/e2e/test-apps/sessions/window-good/recipe.yml b/test/e2e/test-apps/sessions/window-good/recipe.yml new file mode 100644 index 00000000..e3e3a34f --- /dev/null +++ b/test/e2e/test-apps/sessions/window-good/recipe.yml @@ -0,0 +1,4 @@ +description: Good Session - Window +category: Sessions +command: yarn +condition: version.major >= 12 diff --git a/test/e2e/test-apps/sessions/window-good/session-first.json b/test/e2e/test-apps/sessions/window-good/session-first.json new file mode 100644 index 00000000..9ae61330 --- /dev/null +++ b/test/e2e/test-apps/sessions/window-good/session-first.json @@ -0,0 +1,17 @@ +{ + "appId": "277345", + "sentryKey": "37f8a2ee37c0409d8970bc7559c7c7e4", + "method": "envelope", + "data": { + "sid": "{{id}}", + "init": true, + "started": 0, + "timestamp": 0, + "status": "ok", + "errors": 0, + "duration": 0, + "attrs": { + "release": "good-session-window@1.0.0" + } + } +} diff --git a/test/e2e/test-apps/sessions/window-good/session-fourth.json b/test/e2e/test-apps/sessions/window-good/session-fourth.json new file mode 100644 index 00000000..aaec8549 --- /dev/null +++ b/test/e2e/test-apps/sessions/window-good/session-fourth.json @@ -0,0 +1,17 @@ +{ + "appId": "277345", + "sentryKey": "37f8a2ee37c0409d8970bc7559c7c7e4", + "method": "envelope", + "data": { + "sid": "{{id}}", + "init": false, + "started": 0, + "timestamp": 0, + "status": "exited", + "errors": 0, + "duration": 0, + "attrs": { + "release": "good-session-window@1.0.0" + } + } +} diff --git a/test/e2e/test-apps/sessions/window-good/session-second.json b/test/e2e/test-apps/sessions/window-good/session-second.json new file mode 100644 index 00000000..aaec8549 --- /dev/null +++ b/test/e2e/test-apps/sessions/window-good/session-second.json @@ -0,0 +1,17 @@ +{ + "appId": "277345", + "sentryKey": "37f8a2ee37c0409d8970bc7559c7c7e4", + "method": "envelope", + "data": { + "sid": "{{id}}", + "init": false, + "started": 0, + "timestamp": 0, + "status": "exited", + "errors": 0, + "duration": 0, + "attrs": { + "release": "good-session-window@1.0.0" + } + } +} diff --git a/test/e2e/test-apps/sessions/window-good/session-third.json b/test/e2e/test-apps/sessions/window-good/session-third.json new file mode 100644 index 00000000..9ae61330 --- /dev/null +++ b/test/e2e/test-apps/sessions/window-good/session-third.json @@ -0,0 +1,17 @@ +{ + "appId": "277345", + "sentryKey": "37f8a2ee37c0409d8970bc7559c7c7e4", + "method": "envelope", + "data": { + "sid": "{{id}}", + "init": true, + "started": 0, + "timestamp": 0, + "status": "ok", + "errors": 0, + "duration": 0, + "attrs": { + "release": "good-session-window@1.0.0" + } + } +} diff --git a/test/e2e/test-apps/sessions/window-good/src/index.html b/test/e2e/test-apps/sessions/window-good/src/index.html new file mode 100644 index 00000000..b43f77af --- /dev/null +++ b/test/e2e/test-apps/sessions/window-good/src/index.html @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/test/e2e/test-apps/sessions/window-good/src/main.js b/test/e2e/test-apps/sessions/window-good/src/main.js new file mode 100644 index 00000000..6e6e5e65 --- /dev/null +++ b/test/e2e/test-apps/sessions/window-good/src/main.js @@ -0,0 +1,35 @@ +const path = require('path'); + +const { app, BrowserWindow } = require('electron'); +const { init, Integrations } = require('@sentry/electron'); + +init({ + dsn: '__DSN__', + debug: true, + integrations: [new Integrations.BrowserWindowSession({ backgroundTimeoutSeconds: 1 })], + onFatalError: () => {}, +}); + +app.on('ready', () => { + const mainWindow = new BrowserWindow({ + show: true, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + }, + }); + + mainWindow.loadFile(path.join(__dirname, 'index.html')); + + setTimeout(() => { + mainWindow.hide(); + + setTimeout(() => { + mainWindow.show(); + + setTimeout(() => { + app.quit(); + }, 2000); + }, 2000); + }, 2000); +});