Skip to content

Commit d551d23

Browse files
authored
feat(browserProfiling): Add trace lifecycle mode for UI profiling (#17619)
Adds the `trace` lifecycle mode and sends `profile_chunk` envelopes. Also adds test for either overlapping root spans (one chunk) or single root spans (multiple chunks). The "manual" mode comes in another PR to keep this from growing too large. **Browser trace-lifecycle profiler (v2):** - Starts when the first sampled root span starts - Stops when the last sampled root span ends - While running, periodically stops and restarts the JS self-profiling API to collect chunks **Profiles are emitted as standalone `profile_chunk` envelopes either when:** - there are no more sampled root spans, or - the 60s chunk timer elapses while profiling is running. **Handling never-ending root spans** In the trace lifecycle, profiling continues as long as a root span is active. To prevent profiling endlessly, each root span has its own profile timeout and is terminated if it is too long (5 minutes). If another root span is still active, profiling will continue regardless. part of #17279 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds UI profiling trace lifecycle mode that samples sessions, streams profile_chunk envelopes, and attaches thread data, with accompanying tests and type options. > > - **Browser Profiling (UI Profiling v2)**: > - Add `profileLifecycle: 'trace'` with session sampling via `profileSessionSampleRate`; defaults lifecycle to `manual` when unspecified. > - Stream profiling as `profile_chunk` envelopes; periodic chunking (60s) and 5‑min root-span timeout. > - New `BrowserTraceLifecycleProfiler` manages start/stop across root spans and chunk sending. > - Attach profiled thread data to events/spans; warn if trace mode without tracing. > - **Profiling Utils**: > - Convert JS self profile to continuous format; validate chunks; main/worker thread constants; helper to attach thread info. > - Split legacy logic: `hasLegacyProfiling`, `shouldProfileSpanLegacy`, `shouldProfileSession`. > - **Integration Changes**: > - Browser integration branches between legacy and trace lifecycle; adds `processEvent` to attach thread data. > - Minor fix in `startProfileForSpan` (processed profile handling). > - **Tests**: > - Add Playwright suites for trace lifecycle (multiple chunks, overlapping spans) and adjust legacy tests. > - Add unit tests for lifecycle behavior, warnings, profiler_id reuse, and option defaults. > - **Types/Config**: > - Extend `BrowserClientProfilingOptions` with `profileSessionSampleRate` and `profileLifecycle`; refine Node types docs. > - Size-limit: add entry for `@sentry/browser` incl. Tracing, Profiling (48 KB). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 765f89d. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 1bd76c0 commit d551d23

File tree

15 files changed

+1947
-78
lines changed

15 files changed

+1947
-78
lines changed

.size-limit.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ module.exports = [
4040
gzip: true,
4141
limit: '41 KB',
4242
},
43+
{
44+
name: '@sentry/browser (incl. Tracing, Profiling)',
45+
path: 'packages/browser/build/npm/esm/index.js',
46+
import: createImport('init', 'browserTracingIntegration', 'browserProfilingIntegration'),
47+
gzip: true,
48+
limit: '48 KB',
49+
},
4350
{
4451
name: '@sentry/browser (incl. Tracing, Replay)',
4552
path: 'packages/browser/build/npm/esm/index.js',

dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ function fibonacci(n) {
1717
return fibonacci(n - 1) + fibonacci(n - 2);
1818
}
1919

20-
await Sentry.startSpanManual({ name: 'root-fibonacci-2', parentSpan: null, forceTransaction: true }, async span => {
20+
await Sentry.startSpanManual({ name: 'root-fibonacci', parentSpan: null, forceTransaction: true }, async span => {
2121
fibonacci(30);
2222

2323
// Timeout to prevent flaky tests. Integration samples every 20ms, if function is too fast it might not get sampled

dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,16 @@ sentryTest('sends profile envelope in legacy mode', async ({ page, getLocalTestU
7373
expect(profile.frames.length).toBeGreaterThan(0);
7474
for (const frame of profile.frames) {
7575
expect(frame).toHaveProperty('function');
76-
expect(frame).toHaveProperty('abs_path');
77-
expect(frame).toHaveProperty('lineno');
78-
expect(frame).toHaveProperty('colno');
79-
8076
expect(typeof frame.function).toBe('string');
81-
expect(typeof frame.abs_path).toBe('string');
82-
expect(typeof frame.lineno).toBe('number');
83-
expect(typeof frame.colno).toBe('number');
77+
78+
if (frame.function !== 'fetch' && frame.function !== 'setTimeout') {
79+
expect(frame).toHaveProperty('abs_path');
80+
expect(frame).toHaveProperty('lineno');
81+
expect(frame).toHaveProperty('colno');
82+
expect(typeof frame.abs_path).toBe('string');
83+
expect(typeof frame.lineno).toBe('number');
84+
expect(typeof frame.colno).toBe('number');
85+
}
8486
}
8587

8688
const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== '');
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import * as Sentry from '@sentry/browser';
2+
import { browserProfilingIntegration } from '@sentry/browser';
3+
4+
window.Sentry = Sentry;
5+
6+
Sentry.init({
7+
dsn: 'https://[email protected]/1337',
8+
integrations: [browserProfilingIntegration()],
9+
tracesSampleRate: 1,
10+
profileSessionSampleRate: 1,
11+
profileLifecycle: 'trace',
12+
});
13+
14+
function largeSum(amount = 1000000) {
15+
let sum = 0;
16+
for (let i = 0; i < amount; i++) {
17+
sum += Math.sqrt(i) * Math.sin(i);
18+
}
19+
}
20+
21+
function fibonacci(n) {
22+
if (n <= 1) {
23+
return n;
24+
}
25+
return fibonacci(n - 1) + fibonacci(n - 2);
26+
}
27+
28+
// Create two NON-overlapping root spans so that the profiler stops and emits a chunk
29+
// after each span (since active root span count returns to 0 between them).
30+
await Sentry.startSpanManual({ name: 'root-fibonacci-1', parentSpan: null, forceTransaction: true }, async span => {
31+
fibonacci(40);
32+
// Ensure we cross the sampling interval to avoid flakes
33+
await new Promise(resolve => setTimeout(resolve, 25));
34+
span.end();
35+
});
36+
37+
// Small delay to ensure the first chunk is collected and sent
38+
await new Promise(r => setTimeout(r, 25));
39+
40+
await Sentry.startSpanManual({ name: 'root-largeSum-2', parentSpan: null, forceTransaction: true }, async span => {
41+
largeSum();
42+
// Ensure we cross the sampling interval to avoid flakes
43+
await new Promise(resolve => setTimeout(resolve, 25));
44+
span.end();
45+
});
46+
47+
const client = Sentry.getClient();
48+
await client?.flush(5000);
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { expect } from '@playwright/test';
2+
import type { ProfileChunkEnvelope } from '@sentry/core';
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import {
5+
countEnvelopes,
6+
getMultipleSentryEnvelopeRequests,
7+
properFullEnvelopeRequestParser,
8+
shouldSkipTracingTest,
9+
} from '../../../utils/helpers';
10+
11+
sentryTest(
12+
'does not send profile envelope when document-policy is not set',
13+
async ({ page, getLocalTestUrl, browserName }) => {
14+
if (shouldSkipTracingTest() || browserName !== 'chromium') {
15+
// Profiling only works when tracing is enabled
16+
sentryTest.skip();
17+
}
18+
19+
const url = await getLocalTestUrl({ testDir: __dirname });
20+
21+
// Assert that no profile_chunk envelope is sent without policy header
22+
const chunkCount = await countEnvelopes(page, { url, envelopeType: 'profile_chunk', timeout: 1500 });
23+
expect(chunkCount).toBe(0);
24+
},
25+
);
26+
27+
sentryTest(
28+
'sends profile_chunk envelopes in trace mode (multiple chunks)',
29+
async ({ page, getLocalTestUrl, browserName }) => {
30+
if (shouldSkipTracingTest() || browserName !== 'chromium') {
31+
// Profiling only works when tracing is enabled
32+
sentryTest.skip();
33+
}
34+
35+
const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } });
36+
37+
// Expect at least 2 chunks because subject creates two separate root spans,
38+
// causing the profiler to stop and emit a chunk after each root span ends.
39+
const profileChunkEnvelopes = await getMultipleSentryEnvelopeRequests<ProfileChunkEnvelope>(
40+
page,
41+
2,
42+
{ url, envelopeType: 'profile_chunk', timeout: 5000 },
43+
properFullEnvelopeRequestParser,
44+
);
45+
46+
expect(profileChunkEnvelopes.length).toBeGreaterThanOrEqual(2);
47+
48+
// Validate the first chunk thoroughly
49+
const profileChunkEnvelopeItem = profileChunkEnvelopes[0][1][0];
50+
const envelopeItemHeader = profileChunkEnvelopeItem[0];
51+
const envelopeItemPayload1 = profileChunkEnvelopeItem[1];
52+
53+
expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk');
54+
55+
expect(envelopeItemPayload1.profile).toBeDefined();
56+
expect(envelopeItemPayload1.version).toBe('2');
57+
expect(envelopeItemPayload1.platform).toBe('javascript');
58+
59+
// Required profile metadata (Sample Format V2)
60+
expect(typeof envelopeItemPayload1.profiler_id).toBe('string');
61+
expect(envelopeItemPayload1.profiler_id).toMatch(/^[a-f0-9]{32}$/);
62+
expect(typeof envelopeItemPayload1.chunk_id).toBe('string');
63+
expect(envelopeItemPayload1.chunk_id).toMatch(/^[a-f0-9]{32}$/);
64+
expect(envelopeItemPayload1.client_sdk).toBeDefined();
65+
expect(typeof envelopeItemPayload1.client_sdk.name).toBe('string');
66+
expect(typeof envelopeItemPayload1.client_sdk.version).toBe('string');
67+
expect(typeof envelopeItemPayload1.release).toBe('string');
68+
expect(envelopeItemPayload1.debug_meta).toBeDefined();
69+
expect(Array.isArray(envelopeItemPayload1?.debug_meta?.images)).toBe(true);
70+
71+
const profile1 = envelopeItemPayload1.profile;
72+
73+
expect(profile1.samples).toBeDefined();
74+
expect(profile1.stacks).toBeDefined();
75+
expect(profile1.frames).toBeDefined();
76+
expect(profile1.thread_metadata).toBeDefined();
77+
78+
// Samples
79+
expect(profile1.samples.length).toBeGreaterThanOrEqual(2);
80+
let previousTimestamp = Number.NEGATIVE_INFINITY;
81+
for (const sample of profile1.samples) {
82+
expect(typeof sample.stack_id).toBe('number');
83+
expect(sample.stack_id).toBeGreaterThanOrEqual(0);
84+
expect(sample.stack_id).toBeLessThan(profile1.stacks.length);
85+
86+
// In trace lifecycle mode, samples carry a numeric timestamp (ms since epoch or similar clock)
87+
expect(typeof (sample as any).timestamp).toBe('number');
88+
const ts = (sample as any).timestamp as number;
89+
expect(Number.isFinite(ts)).toBe(true);
90+
expect(ts).toBeGreaterThan(0);
91+
// Monotonic non-decreasing timestamps
92+
expect(ts).toBeGreaterThanOrEqual(previousTimestamp);
93+
previousTimestamp = ts;
94+
95+
expect(sample.thread_id).toBe('0'); // Should be main thread
96+
}
97+
98+
// Stacks
99+
expect(profile1.stacks.length).toBeGreaterThan(0);
100+
for (const stack of profile1.stacks) {
101+
expect(Array.isArray(stack)).toBe(true);
102+
for (const frameIndex of stack) {
103+
expect(typeof frameIndex).toBe('number');
104+
expect(frameIndex).toBeGreaterThanOrEqual(0);
105+
expect(frameIndex).toBeLessThan(profile1.frames.length);
106+
}
107+
}
108+
109+
// Frames
110+
expect(profile1.frames.length).toBeGreaterThan(0);
111+
for (const frame of profile1.frames) {
112+
expect(frame).toHaveProperty('function');
113+
expect(typeof frame.function).toBe('string');
114+
115+
if (frame.function !== 'fetch' && frame.function !== 'setTimeout') {
116+
expect(frame).toHaveProperty('abs_path');
117+
expect(frame).toHaveProperty('lineno');
118+
expect(frame).toHaveProperty('colno');
119+
expect(typeof frame.abs_path).toBe('string');
120+
expect(typeof frame.lineno).toBe('number');
121+
expect(typeof frame.colno).toBe('number');
122+
}
123+
}
124+
125+
const functionNames = profile1.frames.map(frame => frame.function).filter(name => name !== '');
126+
127+
if ((process.env.PW_BUNDLE || '').endsWith('min')) {
128+
// In bundled mode, function names are minified
129+
expect(functionNames.length).toBeGreaterThan(0);
130+
expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings
131+
} else {
132+
expect(functionNames).toEqual(
133+
expect.arrayContaining([
134+
'_startRootSpan',
135+
'withScope',
136+
'createChildOrRootSpan',
137+
'startSpanManual',
138+
'startJSSelfProfile',
139+
140+
// first function is captured (other one is in other chunk)
141+
'fibonacci',
142+
]),
143+
);
144+
}
145+
146+
expect(profile1.thread_metadata).toHaveProperty('0');
147+
expect(profile1.thread_metadata['0']).toHaveProperty('name');
148+
expect(profile1.thread_metadata['0'].name).toBe('main');
149+
150+
// Test that profile duration makes sense (should be > 20ms based on test setup)
151+
const startTimeSec = (profile1.samples[0] as any).timestamp as number;
152+
const endTimeSec = (profile1.samples[profile1.samples.length - 1] as any).timestamp as number;
153+
const durationSec = endTimeSec - startTimeSec;
154+
155+
// Should be at least 20ms based on our setTimeout(21) in the test
156+
expect(durationSec).toBeGreaterThan(0.2);
157+
158+
// === PROFILE CHUNK 2 ===
159+
160+
const profileChunkEnvelopeItem2 = profileChunkEnvelopes[1][1][0];
161+
const envelopeItemHeader2 = profileChunkEnvelopeItem2[0];
162+
const envelopeItemPayload2 = profileChunkEnvelopeItem2[1];
163+
164+
// Basic sanity on the second chunk: has correct envelope type and structure
165+
expect(envelopeItemHeader2).toHaveProperty('type', 'profile_chunk');
166+
expect(envelopeItemPayload2.profile).toBeDefined();
167+
expect(envelopeItemPayload2.version).toBe('2');
168+
expect(envelopeItemPayload2.platform).toBe('javascript');
169+
170+
// Required profile metadata (Sample Format V2)
171+
// https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/
172+
expect(typeof envelopeItemPayload2.profiler_id).toBe('string');
173+
expect(envelopeItemPayload2.profiler_id).toMatch(/^[a-f0-9]{32}$/);
174+
expect(typeof envelopeItemPayload2.chunk_id).toBe('string');
175+
expect(envelopeItemPayload2.chunk_id).toMatch(/^[a-f0-9]{32}$/);
176+
expect(envelopeItemPayload2.client_sdk).toBeDefined();
177+
expect(typeof envelopeItemPayload2.client_sdk.name).toBe('string');
178+
expect(typeof envelopeItemPayload2.client_sdk.version).toBe('string');
179+
expect(typeof envelopeItemPayload2.release).toBe('string');
180+
expect(envelopeItemPayload2.debug_meta).toBeDefined();
181+
expect(Array.isArray(envelopeItemPayload2?.debug_meta?.images)).toBe(true);
182+
183+
const profile2 = envelopeItemPayload2.profile;
184+
185+
const functionNames2 = profile2.frames.map(frame => frame.function).filter(name => name !== '');
186+
187+
if ((process.env.PW_BUNDLE || '').endsWith('min')) {
188+
// In bundled mode, function names are minified
189+
expect(functionNames2.length).toBeGreaterThan(0);
190+
expect((functionNames2 as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings
191+
} else {
192+
expect(functionNames2).toEqual(
193+
expect.arrayContaining([
194+
'_startRootSpan',
195+
'withScope',
196+
'createChildOrRootSpan',
197+
'startSpanManual',
198+
'startJSSelfProfile',
199+
200+
// second function is captured (other one is in other chunk)
201+
'largeSum',
202+
]),
203+
);
204+
}
205+
},
206+
);
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import * as Sentry from '@sentry/browser';
2+
import { browserProfilingIntegration } from '@sentry/browser';
3+
4+
window.Sentry = Sentry;
5+
6+
Sentry.init({
7+
dsn: 'https://[email protected]/1337',
8+
integrations: [browserProfilingIntegration()],
9+
tracesSampleRate: 1,
10+
profileSessionSampleRate: 1,
11+
profileLifecycle: 'trace',
12+
});
13+
14+
function largeSum(amount = 1000000) {
15+
let sum = 0;
16+
for (let i = 0; i < amount; i++) {
17+
sum += Math.sqrt(i) * Math.sin(i);
18+
}
19+
}
20+
21+
function fibonacci(n) {
22+
if (n <= 1) {
23+
return n;
24+
}
25+
return fibonacci(n - 1) + fibonacci(n - 2);
26+
}
27+
28+
let firstSpan;
29+
30+
Sentry.startSpanManual({ name: 'root-largeSum-1', parentSpan: null, forceTransaction: true }, span => {
31+
largeSum();
32+
firstSpan = span;
33+
});
34+
35+
await Sentry.startSpanManual({ name: 'root-fibonacci-2', parentSpan: null, forceTransaction: true }, async span => {
36+
fibonacci(40);
37+
38+
Sentry.startSpan({ name: 'child-fibonacci', parentSpan: span }, childSpan => {
39+
console.log('child span');
40+
});
41+
42+
// Timeout to prevent flaky tests. Integration samples every 20ms, if function is too fast it might not get sampled
43+
await new Promise(resolve => setTimeout(resolve, 21));
44+
span.end();
45+
});
46+
47+
await new Promise(r => setTimeout(r, 21));
48+
49+
firstSpan.end();
50+
51+
const client = Sentry.getClient();
52+
await client?.flush(5000);

0 commit comments

Comments
 (0)