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: Add integration to track sessions as app in foreground #725

Merged
merged 16 commits into from
Sep 11, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/electron-normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
97 changes: 97 additions & 0 deletions src/main/integrations/browser-window-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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 = 'active' | 'inactive' | { timer: NodeJS.Timeout };
timfish marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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 = '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 => {
// We need to test all windows for visibility AND focus
const aWindowIsActive = BrowserWindow.getAllWindows().some((window) => window.isVisible() && window.isFocused());
timfish marked this conversation as resolved.
Show resolved Hide resolved

if (aWindowIsActive) {
// We are now active
if (this._state === 'inactive') {
// If we were inactive, start a new session
void startSession(true);
} else if (typeof this._state !== 'string') {
// Clear the timeout since the app has become active again
clearTimeout(this._state.timer);
}

this._state = 'active';
} else {
if (this._state === '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 (typeof this._state !== 'string') {
this._state = 'inactive';
void endSession();
}
}, timeout)
// unref so this timer doesn't block app exit
.unref();

this._state = { timer };
}
}
};
}
1 change: 1 addition & 0 deletions src/main/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
46 changes: 2 additions & 44 deletions src/main/integrations/main-process-session.ts
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand All @@ -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<void> = 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();
};
}
27 changes: 19 additions & 8 deletions src/main/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
}

Expand Down
41 changes: 40 additions & 1 deletion src/main/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<SessionContext | undefined>(sentryCachePath, 'session', undefined);

/** Previous session that did not exit cleanly */
/** Previous session if it did not exit cleanly */
let previousSession: Promise<Partial<Session> | undefined> | undefined = sessionStore.get();

let persistTimer: NodeJS.Timer | undefined;
Expand Down Expand Up @@ -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<void> = 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();
};
8 changes: 8 additions & 0 deletions test/e2e/test-apps/sessions/good-window/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "good-session-window",
"version": "1.0.0",
"main": "src/main.js",
"dependencies": {
"@sentry/electron": "3.0.0"
}
}
4 changes: 4 additions & 0 deletions test/e2e/test-apps/sessions/good-window/recipe.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
description: Good Session - Window
category: Sessions
command: yarn
condition: version.major >= 12
17 changes: 17 additions & 0 deletions test/e2e/test-apps/sessions/good-window/session-first.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
17 changes: 17 additions & 0 deletions test/e2e/test-apps/sessions/good-window/session-fourth.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
17 changes: 17 additions & 0 deletions test/e2e/test-apps/sessions/good-window/session-second.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
17 changes: 17 additions & 0 deletions test/e2e/test-apps/sessions/good-window/session-third.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
15 changes: 15 additions & 0 deletions test/e2e/test-apps/sessions/good-window/src/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
</head>
<body>
<script>
const { init } = require('@sentry/electron');

init({
debug: true,
});
</script>
</body>
</html>
Loading