Skip to content

Commit bfdedd3

Browse files
authored
feat: Add startupTracingIntegration (#1282)
1 parent 8316c4b commit bfdedd3

8 files changed

Lines changed: 436 additions & 1 deletion

File tree

src/main/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ export { normalizePathsIntegration } from './integrations/normalize-paths.js';
184184
export { electronContextIntegration } from './integrations/electron-context.js';
185185
export { gpuContextIntegration } from './integrations/gpu-context.js';
186186
export { rendererEventLoopBlockIntegration } from './integrations/renderer-anr.js';
187+
export { startupTracingIntegration } from './integrations/startup-tracing.js';
187188

188189
export { makeElectronTransport } from './transports/electron-net.js';
189190
export { makeElectronOfflineTransport } from './transports/electron-offline-net.js';
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import {
2+
defineIntegration,
3+
Event,
4+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
5+
setMeasurement,
6+
Span,
7+
SpanStatus,
8+
startSpanManual,
9+
StartSpanOptions,
10+
timestampInSeconds,
11+
} from '@sentry/core';
12+
import { app } from 'electron';
13+
import { ipcMainHooks } from '../ipc.js';
14+
15+
export interface StartupTracingOptions {
16+
/*
17+
* Timeout in seconds to wait before ending the startup transaction
18+
* Defaults to 10 seconds
19+
*/
20+
timeoutSeconds?: number;
21+
}
22+
23+
let cachedRootTransaction: Span | undefined;
24+
/**
25+
* Creates the root startup span lazily because otel hasn't been configured when the integration is setup
26+
*/
27+
function rootTransaction(): Span {
28+
if (!cachedRootTransaction) {
29+
// Calculate the actual start time of the process
30+
const uptimeMs = process.uptime() * 1000;
31+
const startTime = (Date.now() - uptimeMs) / 1000;
32+
33+
startSpanManual(
34+
{
35+
name: 'Startup',
36+
op: 'app.start',
37+
startTime,
38+
attributes: {
39+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.electron.startup',
40+
},
41+
forceTransaction: true,
42+
},
43+
(root) => {
44+
cachedRootTransaction = root;
45+
},
46+
);
47+
}
48+
49+
return cachedRootTransaction as Span;
50+
}
51+
52+
function zeroLengthSpan(options: StartSpanOptions): void {
53+
const startTime = timestampInSeconds();
54+
55+
startSpanManual(
56+
{
57+
...options,
58+
attributes: {
59+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.electron.startup',
60+
...options.attributes,
61+
},
62+
parentSpan: options.parentSpan || rootTransaction(),
63+
startTime,
64+
},
65+
(span) => {
66+
span.end(startTime * 1000);
67+
},
68+
);
69+
}
70+
71+
function waitForRendererPageload(timeout: number): Promise<Event | undefined> {
72+
return new Promise((resolve) => {
73+
const timer = setTimeout(() => {
74+
resolve(undefined);
75+
}, timeout);
76+
ipcMainHooks.once('pageload-transaction', (event, _contents) => {
77+
clearTimeout(timer);
78+
resolve(event);
79+
});
80+
});
81+
}
82+
83+
function parseStatus(status: string): SpanStatus {
84+
if (status === 'ok') {
85+
return { code: 1 };
86+
}
87+
88+
return { code: 2, message: status };
89+
}
90+
91+
function applyRendererSpansAndMeasurements(parentSpan: Span, event: Event | undefined, endTimestamp: number): number {
92+
let lastEndTimestamp = endTimestamp;
93+
94+
if (!event) {
95+
return lastEndTimestamp;
96+
}
97+
98+
const rendererStartTime = event.start_timestamp || event.timestamp;
99+
parentSpan.setAttribute('performance.timeOrigin', rendererStartTime);
100+
101+
startSpanManual(
102+
{
103+
name: event.transaction || 'electron.renderer',
104+
op: 'electron.renderer',
105+
startTime: rendererStartTime,
106+
parentSpan,
107+
attributes: {
108+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.electron.startup',
109+
},
110+
},
111+
(rendererSpan) => {
112+
if (event?.spans?.length) {
113+
for (const spanJson of event.spans) {
114+
const startTime = spanJson.start_timestamp;
115+
const endTime = spanJson.timestamp;
116+
117+
if (endTime) {
118+
lastEndTimestamp = Math.max(lastEndTimestamp, endTime);
119+
}
120+
121+
startSpanManual(
122+
{
123+
name: spanJson.description || 'electron.renderer',
124+
op: spanJson.op,
125+
startTime,
126+
attributes: spanJson.data,
127+
parentSpan: rendererSpan,
128+
},
129+
(span) => {
130+
if (spanJson.status) {
131+
span.setStatus(parseStatus(spanJson.status));
132+
}
133+
134+
span.end((endTime || startTime) * 1000);
135+
},
136+
);
137+
}
138+
}
139+
140+
rendererSpan.end(lastEndTimestamp * 1000);
141+
},
142+
);
143+
144+
if (event.measurements) {
145+
for (const [name, measurement] of Object.entries(event.measurements)) {
146+
setMeasurement(name, measurement.value, measurement.unit, parentSpan);
147+
}
148+
}
149+
150+
if (event.contexts?.trace?.data) {
151+
for (const [key, value] of Object.entries(event.contexts.trace.data)) {
152+
if (!['sentry.op', 'sentry.origin', 'performance.timeOrigin'].includes(key)) {
153+
parentSpan.setAttribute(key, value);
154+
}
155+
}
156+
}
157+
158+
return lastEndTimestamp;
159+
}
160+
161+
/**
162+
* An integration that instruments Electron's startup sequence.
163+
*
164+
* If you also use the `browserTracingIntegration` in the renderer process, the spans created in
165+
* the renderer will be included in the main process's startup transaction. This allows capturing
166+
* from main process start until the browser front-end is ready to use.
167+
*
168+
* Example:
169+
*
170+
* `main.mjs`
171+
* ```js
172+
* import { init, startupTracingIntegration } from '@sentry/electron/main';
173+
*
174+
* init({
175+
* dsn: '__YOUR_DSN__',
176+
* tracesSampleRate: 1.0,
177+
* integrations: [startupTracingIntegration()],
178+
* });
179+
* ```
180+
* `renderer.mjs`
181+
* ```js
182+
* import { init, browserTracingIntegration } from '@sentry/electron/renderer';
183+
*
184+
* init({
185+
* tracesSampleRate: 1.0,
186+
* integrations: [browserTracingIntegration()],
187+
* });
188+
* ```
189+
*/
190+
export const startupTracingIntegration = defineIntegration((options: StartupTracingOptions = {}) => {
191+
return {
192+
name: 'StartupTracing',
193+
setup() {
194+
const fallbackTimeout = setTimeout(() => {
195+
const transaction = rootTransaction();
196+
transaction.setStatus({ code: 2, message: 'Timeout exceeded' });
197+
transaction.end();
198+
}, (options.timeoutSeconds || 10) * 1000);
199+
200+
app.once('will-finish-launching', () => {
201+
zeroLengthSpan({
202+
name: 'will-finish-launching',
203+
op: 'electron.will-finish-launching',
204+
});
205+
});
206+
207+
app.once('ready', () => {
208+
zeroLengthSpan({
209+
name: 'ready',
210+
op: 'electron.ready',
211+
});
212+
});
213+
214+
app.once('web-contents-created', (_, webContents) => {
215+
zeroLengthSpan({
216+
name: 'web-contents-created',
217+
op: 'electron.web-contents.created',
218+
});
219+
220+
webContents.once('dom-ready', async () => {
221+
clearTimeout(fallbackTimeout);
222+
223+
const parentSpan = rootTransaction();
224+
225+
zeroLengthSpan({
226+
name: 'dom-ready',
227+
op: 'electron.web-contents.dom-ready',
228+
});
229+
230+
let lastEndTimestamp = timestampInSeconds();
231+
232+
const event = await waitForRendererPageload((options.timeoutSeconds || 10) * 1000);
233+
234+
lastEndTimestamp = applyRendererSpansAndMeasurements(parentSpan, event, lastEndTimestamp);
235+
236+
parentSpan.end(lastEndTimestamp * 1000);
237+
});
238+
});
239+
},
240+
};
241+
});

src/main/ipc.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { EventEmitter } from 'node:events';
12
import {
23
_INTERNAL_captureSerializedLog,
34
_INTERNAL_captureSerializedMetric,
@@ -24,6 +25,12 @@ import { normalizeProfileChunkEnvelope, normalizeReplayEnvelope } from './normal
2425
import { ElectronMainOptionsInternal } from './sdk.js';
2526
import { SDK_VERSION } from './version.js';
2627

28+
interface IpcMainEvents {
29+
'pageload-transaction': [event: Event, contents: WebContents | undefined];
30+
}
31+
32+
export const ipcMainHooks = new EventEmitter<IpcMainEvents>();
33+
2734
let KNOWN_RENDERERS: Set<number> | undefined;
2835
let WINDOW_ID_TO_WEB_CONTENTS: Map<string, number> | undefined;
2936

@@ -114,6 +121,15 @@ function handleEnvelope(
114121
rendererProfileFromIpc(event, profile);
115122
}
116123

124+
if (
125+
ipcMainHooks.listenerCount('pageload-transaction') > 0 &&
126+
event.type === 'transaction' &&
127+
event.contexts?.trace?.origin === 'auto.pageload.browser'
128+
) {
129+
ipcMainHooks.emit('pageload-transaction', event, contents);
130+
return;
131+
}
132+
117133
captureEventFromRenderer(options, event, dynamicSamplingContext, attachments, contents);
118134
} else {
119135
// Check if this is a profile_chunk envelope (from UI profiling)

src/main/normalize.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export function normalizePaths(event: Event, basePath: string): Event {
5656

5757
if (event.spans) {
5858
for (const span of event.spans) {
59-
if (span.description?.startsWith('file://')) {
59+
if (span.description) {
6060
span.description = normalizeUrlToBase(span.description, basePath);
6161
}
6262
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "startup-tracing",
3+
"description": "Startup Tracing Integration",
4+
"version": "1.0.0",
5+
"main": "src/main.js",
6+
"dependencies": {
7+
"@sentry/electron": "5.6.0"
8+
}
9+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Startup Tracing Test</title>
6+
</head>
7+
<body>
8+
<script>
9+
const { init, browserTracingIntegration } = require('@sentry/electron/renderer');
10+
11+
init({
12+
integrations: [browserTracingIntegration()],
13+
tracesSampleRate: 1,
14+
});
15+
</script>
16+
</body>
17+
</html>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const path = require('path');
2+
3+
const { app, BrowserWindow } = require('electron');
4+
const { init, startupTracingIntegration } = require('@sentry/electron/main');
5+
6+
init({
7+
dsn: '__DSN__',
8+
debug: true,
9+
tracesSampleRate: 1,
10+
integrations: [startupTracingIntegration()],
11+
onFatalError: () => {},
12+
});
13+
14+
app.on('ready', () => {
15+
const mainWindow = new BrowserWindow({
16+
show: false,
17+
webPreferences: {
18+
nodeIntegration: true,
19+
contextIsolation: false,
20+
},
21+
});
22+
23+
mainWindow.loadFile(path.join(__dirname, 'index.html'));
24+
});

0 commit comments

Comments
 (0)