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: Support Electron utility process #991

Merged
merged 6 commits into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ stats.json
/renderer
/main
/common
/utility
/index.*
/integrations.*
/ipc.*
Expand Down
1 change: 1 addition & 0 deletions .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
!/main/**/*
!/renderer/**/*
!/common/**/*
!/utility/**/*
!/index.*
!/integrations.*
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
"./preload": {
"require": "./preload/index.js",
"import": "./esm/preload/index.js"
},
"./utility": {
"require": "./utility/index.js",
"import": "./esm/utility/index.js"
}
},
"repository": "https://github.com/getsentry/sentry-electron.git",
Expand Down
8 changes: 6 additions & 2 deletions rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,12 @@ function bundlePreload(format, input, output) {
}

export default [
transpileFiles('cjs', ['src/index.ts', 'src/main/index.ts', 'src/renderer/index.ts'], '.'),
transpileFiles('esm', ['src/index.ts', 'src/main/index.ts', 'src/renderer/index.ts'], './esm'),
transpileFiles('cjs', ['src/index.ts', 'src/main/index.ts', 'src/renderer/index.ts', 'src/utility/index.ts'], '.'),
transpileFiles(
'esm',
['src/index.ts', 'src/main/index.ts', 'src/renderer/index.ts', 'src/utility/index.ts'],
'./esm',
),
bundlePreload('cjs', 'src/preload/index.ts', './preload/index.js'),
bundlePreload('esm', 'src/preload/index.ts', './esm/preload/index.js'),
];
11 changes: 10 additions & 1 deletion scripts/check-exports.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as browser from '@sentry/browser';
import * as renderer from '../esm/renderer/index.js';
import * as utility from '../esm/utility/index.js';

import * as node from '@sentry/node';

Expand All @@ -13,6 +14,7 @@ const browserExports = Object.keys(browser);
const rendererExports = Object.keys(renderer);
const nodeExports = Object.keys(node);
const mainExports = Object.keys(main);
const utilityExports = Object.keys(utility);

const ignoredBrowser = [
'SDK_VERSION',
Expand Down Expand Up @@ -46,10 +48,13 @@ const ignoredNode = [
'initWithoutDefaultIntegrations',
];

const ignoredUtility = [...ignoredNode, 'anrIntegration'];

const missingRenderer = browserExports.filter((key) => !rendererExports.includes(key) && !ignoredBrowser.includes(key));
const missingMain = nodeExports.filter((key) => !mainExports.includes(key) && !ignoredNode.includes(key));
const missingUtility = nodeExports.filter((key) => !utilityExports.includes(key) && !ignoredUtility.includes(key));

if (missingRenderer.length || missingMain.length) {
if (missingRenderer.length || missingMain.length || missingUtility.length) {
if (missingRenderer.length) {
console.error('Missing renderer exports:', missingRenderer);
}
Expand All @@ -58,5 +63,9 @@ if (missingRenderer.length || missingMain.length) {
console.error('Missing main exports:', missingMain);
}

if (missingUtility.length) {
console.error('Missing utility exports:', missingUtility);
}

process.exit(1);
}
28 changes: 28 additions & 0 deletions src/common/envelope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Attachment, AttachmentItem, Envelope, Event, EventItem, Profile } from '@sentry/types';
import { forEachEnvelopeItem } from '@sentry/utils';

/** Pulls an event and additional envelope items out of an envelope. Returns undefined if there was no event */
export function eventFromEnvelope(envelope: Envelope): [Event, Attachment[], Profile | undefined] | undefined {
let event: Event | undefined;
const attachments: Attachment[] = [];
let profile: Profile | undefined;

forEachEnvelopeItem(envelope, (item, type) => {
if (type === 'event' || type === 'transaction' || type === 'feedback') {
event = Array.isArray(item) ? (item as EventItem)[1] : undefined;
} else if (type === 'attachment') {
const [headers, data] = item as AttachmentItem;

attachments.push({
filename: headers.filename,
attachmentType: headers.attachment_type,
contentType: headers.content_type,
data,
});
} else if (type === 'profile') {
profile = item[1] as unknown as Profile;
}
});

return event ? [event, attachments, profile] : undefined;
}
12 changes: 12 additions & 0 deletions src/common/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ export interface IPCInterface {

export const RENDERER_ID_HEADER = 'sentry-electron-renderer-id';

const UTILITY_PROCESS_MAGIC_MESSAGE_KEY = '__sentry_message_port_message__';

/** Does the message look like the magic message */
export function isMagicMessage(msg: unknown): boolean {
return !!(msg && typeof msg === 'object' && UTILITY_PROCESS_MAGIC_MESSAGE_KEY in msg);
}

/** Get the magic message to send to the utility process */
export function getMagicMessage(): unknown {
return { [UTILITY_PROCESS_MAGIC_MESSAGE_KEY]: true };
}

/**
* We store the IPC interface on window so it's the same for both regular and isolated contexts
*/
Expand Down
30 changes: 3 additions & 27 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { captureEvent, getClient, getCurrentScope, metrics } from '@sentry/node';
import { Attachment, AttachmentItem, Envelope, Event, EventItem, Profile, ScopeData } from '@sentry/types';
import { forEachEnvelopeItem, logger, parseEnvelope, SentryError } from '@sentry/utils';
import { Attachment, Event, ScopeData } from '@sentry/types';
import { logger, parseEnvelope, SentryError } from '@sentry/utils';
import { app, ipcMain, protocol, WebContents, webContents } from 'electron';

import { eventFromEnvelope } from '../common/envelope';
import { IPCChannel, IPCMode, MetricIPCMessage, PROTOCOL_SCHEME, RendererStatus } from '../common/ipc';
import { createRendererAnrStatusHandler } from './anr';
import { registerProtocol } from './electron-normalize';
Expand Down Expand Up @@ -79,31 +80,6 @@ function handleEvent(options: ElectronMainOptionsInternal, jsonEvent: string, co
captureEventFromRenderer(options, event, [], contents);
}

function eventFromEnvelope(envelope: Envelope): [Event, Attachment[], Profile | undefined] | undefined {
let event: Event | undefined;
const attachments: Attachment[] = [];
let profile: Profile | undefined;

forEachEnvelopeItem(envelope, (item, type) => {
if (type === 'event' || type === 'transaction' || type === 'feedback') {
event = Array.isArray(item) ? (item as EventItem)[1] : undefined;
} else if (type === 'attachment') {
const [headers, data] = item as AttachmentItem;

attachments.push({
filename: headers.filename,
attachmentType: headers.attachment_type,
contentType: headers.content_type,
data,
});
} else if (type === 'profile') {
profile = item[1] as unknown as Profile;
}
});

return event ? [event, attachments, profile] : undefined;
}

function handleEnvelope(options: ElectronMainOptionsInternal, env: Uint8Array | string, contents?: WebContents): void {
const envelope = parseEnvelope(env);

Expand Down
8 changes: 7 additions & 1 deletion src/main/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
setNodeAsyncContextStrategy,
} from '@sentry/node';
import { Integration, Options } from '@sentry/types';
import { stackParserFromStackParserOptions } from '@sentry/utils';
import { logger, stackParserFromStackParserOptions } from '@sentry/utils';
import { Session, session, WebContents } from 'electron';

import { IPCMode } from '../common/ipc';
Expand All @@ -36,6 +36,7 @@ import { sentryMinidumpIntegration } from './integrations/sentry-minidump';
import { configureIPC } from './ipc';
import { defaultStackParser } from './stack-parse';
import { ElectronOfflineTransportOptions, makeElectronOfflineTransport } from './transports/electron-offline-net';
import { configureUtilityProcessIPC } from './utility-processes';

/** Get the default integrations for the main process SDK. */
export function getDefaultIntegrations(options: ElectronMainOptions): Integration[] {
Expand Down Expand Up @@ -153,8 +154,13 @@ export function init(userOptions: ElectronMainOptions): void {
integrations: getIntegrationsToSetup(optionsWithDefaults),
};

if (options.debug) {
logger.enable();
}

removeRedundantIntegrations(options);
configureIPC(options);
configureUtilityProcessIPC();

setNodeAsyncContextStrategy();

Expand Down
104 changes: 104 additions & 0 deletions src/main/utility-processes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { captureEvent, getClient } from '@sentry/node';
import { Attachment, Event } from '@sentry/types';
import { logger, parseEnvelope } from '@sentry/utils';
import * as electron from 'electron';

import { eventFromEnvelope } from '../common/envelope';
import { getMagicMessage, isMagicMessage } from '../common/ipc';
import { mergeEvents } from './merge';

function log(message: string): void {
logger.log(`[Utility Process] ${message}`);
}

/**
* We wrap `electron.utilityProcess.fork` so we can pass a messageport to any SDK running in the utility process
*/
export function configureUtilityProcessIPC(): void {
if (!electron.utilityProcess?.fork) {
return;
}

// eslint-disable-next-line @typescript-eslint/unbound-method
electron.utilityProcess.fork = new Proxy(electron.utilityProcess.fork, {
apply: (target, thisArg, args: Parameters<typeof electron.utilityProcess.fork>) => {
// Call the underlying function to get the child process
const child: electron.UtilityProcess = target.apply(thisArg, args);

function getProcessName(): string {
const [, , options] = args;
return options?.serviceName || `pid:${child.pid}`;
}

// We don't send any messages unless we've heard from the child SDK. At that point we know it's ready to receive
// and will also filter out any messages we send so users don't see them
child.on('message', (msg: unknown) => {
if (isMagicMessage(msg)) {
log(`SDK started in utility process '${getProcessName()}'`);

const { port1, port2 } = new electron.MessageChannelMain();

port2.on('message', (msg) => {
if (msg.data instanceof Uint8Array || typeof msg.data === 'string') {
handleEnvelopeFromUtility(msg.data);
}
});
port2.start();

// Send one side of the message port to the child SDK
child.postMessage(getMagicMessage(), [port1]);
}
});

// We proxy child.on so we can filter messages from the child SDK and ensure that users do not see them
// eslint-disable-next-line @typescript-eslint/unbound-method
child.on = new Proxy(child.on, {
apply: (target, thisArg, [event, listener]) => {
if (event === 'message') {
return target.apply(thisArg, [
'message',
(msg: unknown) => {
if (isMagicMessage(msg)) {
return;
}

return listener(msg);
},
]);
}

return target.apply(thisArg, [event, listener]);
},
});

return child;
},
});
}

function handleEnvelopeFromUtility(env: Uint8Array | string): void {
const envelope = parseEnvelope(env);

const eventAndAttachments = eventFromEnvelope(envelope);
if (eventAndAttachments) {
const [event, attachments] = eventAndAttachments;

captureEventFromUtility(event, attachments);
} else {
// Pass other types of envelope straight to the transport
void getClient()?.getTransport()?.send(envelope);
}
}

function captureEventFromUtility(event: Event, attachments: Attachment[]): void {
// Remove the environment as it defaults to 'production' and overwrites the main process environment
delete event.environment;
delete event.release;

// Remove the SDK info as we want the Electron SDK to be the one reporting the event
delete event.sdk?.name;
delete event.sdk?.version;
delete event.sdk?.packages;

captureEvent(mergeEvents(event, { tags: { 'event.process': 'utility' } }), { attachments });
}
Loading
Loading