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: Browser profiling #799

Merged
merged 2 commits into from
Dec 18, 2023
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
13 changes: 12 additions & 1 deletion src/common/normalize.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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);
}
}
}
1 change: 1 addition & 0 deletions src/main/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
136 changes: 136 additions & 0 deletions src/main/integrations/renderer-profiling.ts
Original file line number Diff line number Diff line change
@@ -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<string, Profile> | 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<string, string | string[]> = {},
): 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) {
AbhiPrasad marked this conversation as resolved.
Show resolved Hide resolved
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]);
AbhiPrasad marked this conversation as resolved.
Show resolved Hide resolved
});
}
}
19 changes: 15 additions & 4 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<number> | undefined;
Expand Down Expand Up @@ -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') {
Expand All @@ -104,18 +106,27 @@ 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 {
const envelope = parseEnvelope(env, new TextEncoder(), new TextDecoder());

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());
Expand Down
9 changes: 9 additions & 0 deletions src/main/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
Net,
OnUncaughtException,
PreloadInjection,
RendererProfiling,
Screenshots,
SentryMinidump,
} from './integrations';
Expand All @@ -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',
),
Expand Down Expand Up @@ -77,6 +79,13 @@ export interface ElectronMainOptionsInternal extends Options<ElectronOfflineTran
* renderers.
*/
attachScreenshot?: boolean;

/**
* Enables injection of 'js-profiling' document policy headers and ensure profiles are forwarded with transactions
*
* Requires Electron 15+
*/
enableRendererProfiling?: boolean;
}

// getSessions and ipcMode properties are optional because they have defaults
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/recipe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ export class RecipeRunner {
}

for (const event of testServer.events) {
event.data = normalize(event.data);
normalize(event);
}

for (const [i, expectedEvent] of expectedEvents.entries()) {
Expand Down
29 changes: 19 additions & 10 deletions test/e2e/recipe/normalize.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
/* eslint-disable complexity */
import { Event, ReplayEvent, Session, Transaction } from '@sentry/types';
import { Event, Profile, ReplayEvent, Session, Transaction } from '@sentry/types';

import { TestServerEvent } from '../server';

type EventOrSession = Event | Transaction | Session;

export function normalize(event: EventOrSession): EventOrSession {
if (eventIsSession(event)) {
return normalizeSession(event as Session);
export function normalize(event: TestServerEvent<Event | Transaction | Session>): 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 {
Expand All @@ -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}}';
}
Expand All @@ -37,8 +41,6 @@ function normalizeSession(session: Session): Session {
if (session.duration) {
session.duration = 0;
}

return session;
}

/**
Expand All @@ -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}}';
}
Expand Down Expand Up @@ -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}}';
}
10 changes: 9 additions & 1 deletion test/e2e/server/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -38,6 +38,8 @@ export interface TestServerEvent<T = unknown> {
namespacedData?: Record<string, any>;
/** Attachments */
attachments?: Attachment[];
/** Profiling data */
profile?: Profile;
/** API method used for submission */
method: 'envelope' | 'minidump' | 'store';
}
Expand Down Expand Up @@ -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') {
Expand All @@ -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',
Expand Down
Loading
Loading