diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 293fbd32..6bdc6116 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -36,6 +36,7 @@ import { rendererProfileFromIpc } from './integrations/renderer-profiling.js'; import { getOsDeviceLogAttributes } from './log.js'; import { mergeEvents } from './merge.js'; import { normalizeProfileChunkEnvelope, normalizeReplayEnvelope, normalizeSpanStreamingEnvelope } from './normalize.js'; +import { markRendererEvent } from './renderer-event.js'; import type { ElectronMainOptionsInternal } from './sdk.js'; import { SDK_VERSION } from './version.js'; @@ -98,6 +99,8 @@ function prepareRendererEvent( event.sdkProcessingMetadata = { ...event.sdkProcessingMetadata, dynamicSamplingContext }; } + markRendererEvent(event); + return mergeEvents(event, { tags: { 'event.process': process } }); } diff --git a/src/main/renderer-event.ts b/src/main/renderer-event.ts new file mode 100644 index 00000000..bf6cd422 --- /dev/null +++ b/src/main/renderer-event.ts @@ -0,0 +1,59 @@ +import type { Event, Integration } from '@sentry/core'; +import { isAbsolute, relative, resolve } from 'path'; + +const RENDERER_EVENT_KEY = Symbol('sentry-electron-renderer-event'); + +/** Marks the event as sent from a renderer process */ +export function markRendererEvent(event: Event): void { + event.sdkProcessingMetadata = { ...event.sdkProcessingMetadata, [RENDERER_EVENT_KEY]: true }; +} + +/** Was the event sent from a renderer process */ +export function isRendererEvent(event: Event): boolean { + return (event.sdkProcessingMetadata as Record | undefined)?.[RENDERER_EVENT_KEY] === true; +} + +function isPathInDirectory(path: string | undefined, directory: string): boolean { + if (!path || !isAbsolute(path)) { + return false; + } + + const rel = relative(resolve(directory), resolve(path)); + return !!rel && !rel.startsWith('..') && !isAbsolute(rel); +} + +/** + * Renderer stack frames can only get main process context lines from files + * in the app directory, such as preload scripts. + */ +export function limitContextLinesForRendererEvents(integration: Integration, getAppPath: () => string): Integration { + const processEvent = integration.processEvent?.bind(integration); + + if (integration.name !== 'ContextLines' || !processEvent) { + return integration; + } + + return { + ...integration, + processEvent: async (event, hint, client) => { + if (!isRendererEvent(event)) { + return processEvent(event, hint, client); + } + + const appPath = getAppPath(); + // ContextLines adds context to the frame objects so the frames in the + // original event get it too + const values = (event.exception?.values || []).map((exception) => ({ + ...exception, + stacktrace: exception.stacktrace && { + ...exception.stacktrace, + frames: exception.stacktrace.frames?.filter((frame) => isPathInDirectory(frame.filename, appPath)), + }, + })); + + await processEvent({ ...event, exception: { values } }, hint, client); + + return event; + }, + }; +} diff --git a/src/main/sdk.ts b/src/main/sdk.ts index 44c8ad17..5e2763c2 100644 --- a/src/main/sdk.ts +++ b/src/main/sdk.ts @@ -22,7 +22,7 @@ import { setNodeAsyncContextStrategy, } from '@sentry/node'; import type { Session, WebContents } from 'electron'; -import { session } from 'electron'; +import { app, session } from 'electron'; import { IPCMode } from '../common/ipc.js'; import { getDefaultEnvironment, getDefaultReleaseName, getSdkInfo } from './context.js'; import { additionalContextIntegration } from './integrations/additional-context.js'; @@ -41,6 +41,7 @@ import { screenshotsIntegration } from './integrations/screenshots.js'; import { sentryMinidumpIntegration } from './integrations/sentry-minidump/index.js'; import { configureIPC } from './ipc.js'; import { getOsDeviceLogAttributes } from './log.js'; +import { limitContextLinesForRendererEvents } from './renderer-event.js'; import { defaultStackParser } from './stack-parse.js'; import type { ElectronOfflineTransportOptions } from './transports/electron-offline-net.js'; import { makeElectronOfflineTransport } from './transports/electron-offline-net.js'; @@ -198,6 +199,7 @@ export function init(userOptions: ElectronMainOptions): void { } removeRedundantIntegrations(options); + options.integrations = options.integrations.map((i) => limitContextLinesForRendererEvents(i, () => app.getAppPath())); configureUtilityProcessIPC(); setNodeAsyncContextStrategy(); diff --git a/test/unit/renderer-event.test.ts b/test/unit/renderer-event.test.ts new file mode 100644 index 00000000..0ee36818 --- /dev/null +++ b/test/unit/renderer-event.test.ts @@ -0,0 +1,69 @@ +import type { Event, Integration, StackFrame } from '@sentry/core'; +import { join } from 'path'; +import { describe, expect, test, vi } from 'vitest'; +import { limitContextLinesForRendererEvents, markRendererEvent } from '../../src/main/renderer-event'; + +const APP_PATH = join(__dirname, 'app'); + +function createIntegration(name: string): { integration: Integration; processEvent: ReturnType } { + const processEvent = vi.fn((event: Event) => { + for (const frame of event.exception?.values?.[0]?.stacktrace?.frames || []) { + frame.context_line = 'context'; + } + return event; + }); + return { integration: { name, processEvent }, processEvent }; +} + +function createEvent(filenames: string[]): Event { + return { + exception: { + values: [{ type: 'Error', stacktrace: { frames: filenames.map((filename) => ({ filename, lineno: 1 })) } }], + }, + }; +} + +function frames(event: Event | null | PromiseLike): StackFrame[] { + return (event as Event).exception?.values?.[0]?.stacktrace?.frames || []; +} + +describe('limitContextLinesForRendererEvents', () => { + test('Only adds context lines to renderer frames in the app directory', async () => { + const { integration } = createIntegration('ContextLines'); + const wrapped = limitContextLinesForRendererEvents(integration, () => APP_PATH); + + const inApp = join(APP_PATH, 'src', 'preload.js'); + const outsideApp = join(__dirname, 'other', 'file.js'); + const event = createEvent([ + inApp, + outsideApp, + join(APP_PATH, '..', 'app-other', 'file.js'), + 'app:///src/index.html', + ]); + markRendererEvent(event); + + const result = await wrapped.processEvent?.(event, {}, {} as never); + + expect(wrapped).not.toBe(integration); + expect(result).toBe(event); + expect(frames(event).map((frame) => frame.context_line)).toEqual(['context', undefined, undefined, undefined]); + }); + + test('Adds context lines to all frames of other events', async () => { + const { integration, processEvent } = createIntegration('ContextLines'); + const wrapped = limitContextLinesForRendererEvents(integration, () => APP_PATH); + + const event = createEvent([join(__dirname, 'other', 'file.js')]); + + const result = await wrapped.processEvent?.(event, {}, {} as never); + + expect(processEvent).toHaveBeenCalledTimes(1); + expect(frames(result as Event).map((frame) => frame.context_line)).toEqual(['context']); + }); + + test('Does not change other integrations', () => { + const { integration } = createIntegration('NormalizePaths'); + + expect(limitContextLinesForRendererEvents(integration, () => APP_PATH)).toBe(integration); + }); +});