diff --git a/src/common/normalize.ts b/src/common/normalize.ts index 4ccb50d9..c2eda157 100644 --- a/src/common/normalize.ts +++ b/src/common/normalize.ts @@ -1,4 +1,4 @@ -import { Envelope, Event, ReplayEvent } from '@sentry/types'; +import { Envelope, Event, Profile, ReplayEvent } from '@sentry/types'; import { addItemToEnvelope, createEnvelope, forEachEnvelopeItem } from '@sentry/utils'; /** @@ -111,3 +111,14 @@ export function normalizeUrlsInReplayEnvelope(envelope: Envelope, basePath: stri return isReplay ? modifiedEnvelope : envelope; } + +/** + * Normalizes all URLs in a profile + */ +export function normaliseProfile(profile: Profile, basePath: string): void { + for (const frame of profile.profile.frames) { + if (frame.abs_path) { + frame.abs_path = normalizeUrl(frame.abs_path, basePath); + } + } +} diff --git a/src/main/integrations/index.ts b/src/main/integrations/index.ts index e723f064..9c3a5fd3 100644 --- a/src/main/integrations/index.ts +++ b/src/main/integrations/index.ts @@ -10,3 +10,4 @@ export { AdditionalContext } from './additional-context'; export { Net } from './net-breadcrumbs'; export { ChildProcess } from './child-process'; export { Screenshots } from './screenshots'; +export { RendererProfiling } from './renderer-profiling'; diff --git a/src/main/integrations/renderer-profiling.ts b/src/main/integrations/renderer-profiling.ts new file mode 100644 index 00000000..84ff10e5 --- /dev/null +++ b/src/main/integrations/renderer-profiling.ts @@ -0,0 +1,136 @@ +import { NodeClient } from '@sentry/node'; +import { Event, Integration, Profile } from '@sentry/types'; +import { forEachEnvelopeItem, LRUMap } from '@sentry/utils'; +import { app } from 'electron'; + +import { normaliseProfile } from '../../common'; +import { getDefaultEnvironment, getDefaultReleaseName } from '../context'; +import { ELECTRON_MAJOR_VERSION } from '../electron-normalize'; +import { ElectronMainOptionsInternal } from '../sdk'; + +const DOCUMENT_POLICY_HEADER = 'Document-Policy'; +const JS_PROFILING_HEADER = 'js-profiling'; + +// A cache of renderer profiles which need attaching to events +let RENDERER_PROFILES: LRUMap | undefined; + +/** + * Caches a profile to later be re-attached to an event + */ +export function rendererProfileFromIpc(event: Event, profile: Profile): void { + if (!RENDERER_PROFILES) { + return; + } + + const profile_id = profile.event_id; + RENDERER_PROFILES.set(profile_id, profile); + + if (event) { + event.contexts = { + ...event.contexts, + // Re-add the profile context which we can later use to find the correct profile + profile: { + profile_id, + }, + }; + } +} + +function addJsProfilingHeader( + responseHeaders: Record = {}, +): Electron.HeadersReceivedResponse { + if (responseHeaders[DOCUMENT_POLICY_HEADER]) { + const docPolicy = responseHeaders[DOCUMENT_POLICY_HEADER]; + + if (Array.isArray(docPolicy)) { + docPolicy.push(JS_PROFILING_HEADER); + } else { + responseHeaders[DOCUMENT_POLICY_HEADER] = [docPolicy, JS_PROFILING_HEADER]; + } + } else { + responseHeaders[DOCUMENT_POLICY_HEADER] = JS_PROFILING_HEADER; + } + + return { responseHeaders }; +} + +/** + * Injects 'js-profiling' document policy headers and ensures that profiles get forwarded with transactions + */ +export class RendererProfiling implements Integration { + /** @inheritDoc */ + public static id: string = 'RendererProfiling'; + + /** @inheritDoc */ + public readonly name: string; + + public constructor() { + this.name = RendererProfiling.id; + } + + /** @inheritDoc */ + public setupOnce(): void { + // + } + + /** @inheritDoc */ + public setup(client: NodeClient): void { + const options = client.getOptions() as ElectronMainOptionsInternal; + if (!options.enableRendererProfiling) { + return; + } + + if (ELECTRON_MAJOR_VERSION < 15) { + throw new Error('Renderer profiling requires Electron 15+ (Chromium 94+)'); + } + + RENDERER_PROFILES = new LRUMap(10); + + app.on('ready', () => { + // Ensure the correct headers are set to enable the browser profiler + for (const sesh of options.getSessions()) { + sesh.webRequest.onHeadersReceived((details, callback) => { + callback(addJsProfilingHeader(details.responseHeaders)); + }); + } + }); + + // Copy the profiles back into the event envelopes + client.on('beforeEnvelope', (envelope) => { + let profile_id: string | undefined; + + forEachEnvelopeItem(envelope, (item, type) => { + if (type !== 'transaction') { + return; + } + + for (let j = 1; j < item.length; j++) { + const event = item[j] as Event; + + if (event && event.contexts && event.contexts.profile && event.contexts.profile.profile_id) { + profile_id = event.contexts.profile.profile_id as string; + // This can be removed as it's no longer needed + delete event.contexts.profile; + } + } + }); + + if (!profile_id) { + return; + } + + const profile = RENDERER_PROFILES?.remove(profile_id); + + if (!profile) { + return; + } + + normaliseProfile(profile, app.getAppPath()); + profile.release = options.release || getDefaultReleaseName(); + profile.environment = options.environment || getDefaultEnvironment(); + + // @ts-expect-error untyped envelope + envelope[1].push([{ type: 'profile' }, profile]); + }); + } +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 4a4772de..8d80a37a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,5 +1,5 @@ import { captureEvent, configureScope, getCurrentHub, Scope } from '@sentry/core'; -import { Attachment, AttachmentItem, Envelope, Event, EventItem } from '@sentry/types'; +import { Attachment, AttachmentItem, Envelope, Event, EventItem, Profile } from '@sentry/types'; import { forEachEnvelopeItem, logger, parseEnvelope, SentryError } from '@sentry/utils'; import { app, ipcMain, protocol, WebContents, webContents } from 'electron'; import { TextDecoder, TextEncoder } from 'util'; @@ -14,6 +14,7 @@ import { } from '../common'; import { createRendererAnrStatusHandler } from './anr'; import { registerProtocol, supportsFullProtocol, whenAppReady } from './electron-normalize'; +import { rendererProfileFromIpc } from './integrations/renderer-profiling'; import { ElectronMainOptionsInternal } from './sdk'; let KNOWN_RENDERERS: Set | undefined; @@ -88,9 +89,10 @@ function handleEvent(options: ElectronMainOptionsInternal, jsonEvent: string, co captureEventFromRenderer(options, event, [], contents); } -function eventFromEnvelope(envelope: Envelope): [Event, Attachment[]] | undefined { +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') { @@ -104,10 +106,12 @@ function eventFromEnvelope(envelope: Envelope): [Event, Attachment[]] | undefine contentType: headers.content_type, data, }); + } else if (type === 'profile') { + profile = item[1] as unknown as Profile; } }); - return event ? [event, attachments] : undefined; + return event ? [event, attachments, profile] : undefined; } function handleEnvelope(options: ElectronMainOptionsInternal, env: Uint8Array | string, contents?: WebContents): void { @@ -115,7 +119,14 @@ function handleEnvelope(options: ElectronMainOptionsInternal, env: Uint8Array | const eventAndAttachments = eventFromEnvelope(envelope); if (eventAndAttachments) { - const [event, attachments] = eventAndAttachments; + const [event, attachments, profile] = eventAndAttachments; + + if (profile) { + // We have a 'profile' item and there is no way for us to pass this through event capture + // so store them in a cache and reattach them via the `beforeEnvelope` hook before sending + rendererProfileFromIpc(event, profile); + } + captureEventFromRenderer(options, event, attachments, contents); } else { const normalizedEnvelope = normalizeUrlsInReplayEnvelope(envelope, app.getAppPath()); diff --git a/src/main/sdk.ts b/src/main/sdk.ts index b0ab3c04..81755ff6 100644 --- a/src/main/sdk.ts +++ b/src/main/sdk.ts @@ -16,6 +16,7 @@ import { Net, OnUncaughtException, PreloadInjection, + RendererProfiling, Screenshots, SentryMinidump, } from './integrations'; @@ -34,6 +35,7 @@ export const defaultIntegrations: Integration[] = [ new PreloadInjection(), new AdditionalContext(), new Screenshots(), + new RendererProfiling(), ...defaultNodeIntegrations.filter( (integration) => integration.name !== 'OnUncaughtException' && integration.name !== 'Context', ), @@ -77,6 +79,13 @@ export interface ElectronMainOptionsInternal extends Options): void { + if (eventIsSession(event.data)) { + normalizeSession(event.data as Session); } else { - return normalizeEvent(event as Event & ReplayEvent); + normalizeEvent(event.data as Event & ReplayEvent); } + + normalizeProfile(event.profile); } export function eventIsSession(data: EventOrSession): boolean { @@ -21,7 +25,7 @@ export function eventIsSession(data: EventOrSession): boolean { * All properties that are timestamps, versions, ids or variables that may vary * by platform are replaced with placeholder strings */ -function normalizeSession(session: Session): Session { +function normalizeSession(session: Session): void { if (session.sid) { session.sid = '{{id}}'; } @@ -37,8 +41,6 @@ function normalizeSession(session: Session): Session { if (session.duration) { session.duration = 0; } - - return session; } /** @@ -47,7 +49,7 @@ function normalizeSession(session: Session): Session { * All properties that are timestamps, versions, ids or variables that may vary * by platform are replaced with placeholder strings */ -function normalizeEvent(event: Event & ReplayEvent): Event { +function normalizeEvent(event: Event & ReplayEvent): void { if (event.sdk?.version) { event.sdk.version = '{{version}}'; } @@ -193,6 +195,13 @@ function normalizeEvent(event: Event & ReplayEvent): Event { breadcrumb.timestamp = 0; } } +} + +export function normalizeProfile(profile: Profile | undefined): void { + if (!profile) { + return; + } - return event; + profile.event_id = '{{id}}'; + profile.timestamp = '{{time}}'; } diff --git a/test/e2e/server/index.ts b/test/e2e/server/index.ts index aee950a9..00e501f3 100644 --- a/test/e2e/server/index.ts +++ b/test/e2e/server/index.ts @@ -1,4 +1,4 @@ -import { Event, ReplayEvent, Session, Transaction } from '@sentry/types'; +import { Event, Profile, ReplayEvent, Session, Transaction } from '@sentry/types'; import { forEachEnvelopeItem, parseEnvelope } from '@sentry/utils'; import { Server } from 'http'; import Koa from 'koa'; @@ -38,6 +38,8 @@ export interface TestServerEvent { namespacedData?: Record; /** Attachments */ attachments?: Attachment[]; + /** Profiling data */ + profile?: Profile; /** API method used for submission */ method: 'envelope' | 'minidump' | 'store'; } @@ -121,6 +123,7 @@ export class TestServer { let data: Event | Transaction | Session | ReplayEvent | undefined; const attachments: Attachment[] = []; + let profile: Profile | undefined; forEachEnvelopeItem(envelope, ([headers, item]) => { if (headers.type === 'event' || headers.type === 'transaction' || headers.type === 'session') { @@ -138,12 +141,17 @@ export class TestServer { if (headers.type === 'attachment') { attachments.push(headers); } + + if (headers.type === 'profile') { + profile = item as unknown as Profile; + } }); if (data) { this._addEvent({ data, attachments, + profile, appId: ctx.params.id, sentryKey: keyMatch[1], method: 'envelope', diff --git a/test/e2e/test-apps/other/browser-profiling/event.json b/test/e2e/test-apps/other/browser-profiling/event.json new file mode 100644 index 00000000..b2e74eaf --- /dev/null +++ b/test/e2e/test-apps/other/browser-profiling/event.json @@ -0,0 +1,141 @@ +{ + "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": "browser-profiling", + "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}}" + } + }, + "spans": [ + { + "description": "PBKDF2", + "origin": "manual", + "parent_span_id": "{{id}}", + "span_id": "{{id}}", + "start_timestamp": 0, + "timestamp": 0, + "trace_id": "{{id}}" + }, + { + "description": "PBKDF2", + "origin": "manual", + "parent_span_id": "{{id}}", + "span_id": "{{id}}", + "start_timestamp": 0, + "timestamp": 0, + "trace_id": "{{id}}" + }, + { + "description": "PBKDF2", + "origin": "manual", + "parent_span_id": "{{id}}", + "span_id": "{{id}}", + "start_timestamp": 0, + "timestamp": 0, + "trace_id": "{{id}}" + }, + { + "description": "PBKDF2", + "origin": "manual", + "parent_span_id": "{{id}}", + "span_id": "{{id}}", + "start_timestamp": 0, + "timestamp": 0, + "trace_id": "{{id}}" + } + ], + "release": "some-release", + "environment": "development", + "user": { + "ip_address": "{{auto}}" + }, + "event_id": "{{id}}", + "platform": "javascript", + "start_timestamp": 0, + "timestamp": 0, + "breadcrumbs": [], + "request": { + "url": "app:///src/index.html" + }, + "tags": { + "event.environment": "javascript", + "event.origin": "electron", + "event_type": "javascript" + } + }, + "profile": { + "event_id": "{{id}}", + "timestamp": "{{time}}", + "release": "some-release", + "environment": "development", + "profile": { + "samples": [ + { + "stack_id": 0, + "thread_id": "0" + }, + { + "stack_id": 0, + "thread_id": "0" + }, + { + "stack_id": 0, + "thread_id": "0" + } + ], + "thread_metadata": { "0": { "name": "main" } } + }, + "transactions": [ + { + "name": "Long work" + } + ] + } +} diff --git a/test/e2e/test-apps/other/browser-profiling/package.json b/test/e2e/test-apps/other/browser-profiling/package.json new file mode 100644 index 00000000..a9f501c7 --- /dev/null +++ b/test/e2e/test-apps/other/browser-profiling/package.json @@ -0,0 +1,8 @@ +{ + "name": "browser-profiling", + "version": "1.0.0", + "main": "src/main.js", + "dependencies": { + "@sentry/electron": "3.0.0" + } +} diff --git a/test/e2e/test-apps/other/browser-profiling/recipe.yml b/test/e2e/test-apps/other/browser-profiling/recipe.yml new file mode 100644 index 00000000..ea4705f3 --- /dev/null +++ b/test/e2e/test-apps/other/browser-profiling/recipe.yml @@ -0,0 +1,4 @@ +description: Browser Profiling +command: yarn +# Browser Tracing fails on Electron v24 +condition: version.major >= 15 diff --git a/test/e2e/test-apps/other/browser-profiling/src/index.html b/test/e2e/test-apps/other/browser-profiling/src/index.html new file mode 100644 index 00000000..4b296c44 --- /dev/null +++ b/test/e2e/test-apps/other/browser-profiling/src/index.html @@ -0,0 +1,47 @@ + + + + + + + + + diff --git a/test/e2e/test-apps/other/browser-profiling/src/main.js b/test/e2e/test-apps/other/browser-profiling/src/main.js new file mode 100644 index 00000000..21736f0d --- /dev/null +++ b/test/e2e/test-apps/other/browser-profiling/src/main.js @@ -0,0 +1,25 @@ +const path = require('path'); + +const { app, BrowserWindow } = require('electron'); +const { init } = require('@sentry/electron'); + +init({ + dsn: '__DSN__', + debug: true, + release: 'some-release', + autoSessionTracking: false, + enableRendererProfiling: true, + onFatalError: () => {}, +}); + +app.on('ready', () => { + const mainWindow = new BrowserWindow({ + show: false, + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + }, + }); + + mainWindow.loadFile(path.join(__dirname, 'index.html')); +});