Skip to content

Commit c2cb472

Browse files
committed
add trace lifecycle profiler
1 parent 27b4026 commit c2cb472

File tree

10 files changed

+942
-72
lines changed

10 files changed

+942
-72
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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+
// Ensure envelope flush
48+
const client = Sentry.getClient();
49+
await client?.flush(5000);

dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode/test.ts renamed to dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { expect } from '@playwright/test';
2-
import type { Event, Profile, ProfileChunkEnvelope } from '@sentry/core';
2+
import type { ProfileChunkEnvelope } from '@sentry/core';
33
import { sentryTest } from '../../../utils/fixtures';
44
import {
5+
countEnvelopes,
56
getMultipleSentryEnvelopeRequests,
6-
properEnvelopeRequestParser,
77
properFullEnvelopeRequestParser,
88
shouldSkipTracingTest,
9-
waitForTransactionRequestOnUrl,
109
} from '../../../utils/helpers';
1110

1211
sentryTest('does not send profile envelope when document-policy is not set', async ({ page, getLocalTestUrl }) => {
@@ -17,16 +16,12 @@ sentryTest('does not send profile envelope when document-policy is not set', asy
1716

1817
const url = await getLocalTestUrl({ testDir: __dirname });
1918

20-
const req = await waitForTransactionRequestOnUrl(page, url);
21-
const transactionEvent = properEnvelopeRequestParser<Event>(req, 0);
22-
const profileEvent = properEnvelopeRequestParser<Profile>(req, 1);
23-
24-
expect(transactionEvent).toBeDefined();
25-
26-
expect(profileEvent).toBeUndefined();
19+
// Assert that no profile_chunk envelope is sent without policy header
20+
const chunkCount = await countEnvelopes(page, { url, envelopeType: 'profile_chunk', timeout: 1500 });
21+
expect(chunkCount).toBe(0);
2722
});
2823

29-
sentryTest('sends profile envelope in trace mode', async ({ page, getLocalTestUrl, browserName }) => {
24+
sentryTest('sends profile_chunk envelopes in trace mode (multiple chunks)', async ({ page, getLocalTestUrl, browserName }) => {
3025
if (shouldSkipTracingTest() || browserName !== 'chromium') {
3126
// Profiling only works when tracing is enabled
3227
sentryTest.skip();
@@ -35,14 +30,19 @@ sentryTest('sends profile envelope in trace mode', async ({ page, getLocalTestUr
3530
const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } });
3631
await page.goto(url);
3732

38-
const profileChunkEnvelopePromise = getMultipleSentryEnvelopeRequests<ProfileChunkEnvelope>(
33+
// Expect at least 2 chunks because subject creates two separate root spans,
34+
// causing the profiler to stop and emit a chunk after each root span ends.
35+
const profileChunkEnvelopes = await getMultipleSentryEnvelopeRequests<ProfileChunkEnvelope>(
3936
page,
40-
1,
41-
{ envelopeType: 'profile_chunk' },
37+
2,
38+
{ envelopeType: 'profile_chunk', timeout: 5000 },
4239
properFullEnvelopeRequestParser,
4340
);
4441

45-
const profileChunkEnvelopeItem = (await profileChunkEnvelopePromise)[0][1][0];
42+
expect(profileChunkEnvelopes.length).toBeGreaterThanOrEqual(2);
43+
44+
// Validate the first chunk thoroughly
45+
const profileChunkEnvelopeItem = profileChunkEnvelopes[0][1][0];
4646
const envelopeItemHeader = profileChunkEnvelopeItem[0];
4747
const envelopeItemPayload = profileChunkEnvelopeItem[1];
4848

@@ -137,4 +137,13 @@ sentryTest('sends profile envelope in trace mode', async ({ page, getLocalTestUr
137137

138138
// Should be at least 20ms based on our setTimeout(21) in the test
139139
expect(durationMs).toBeGreaterThan(20);
140+
141+
// Basic sanity on the second chunk: has correct envelope type and structure
142+
const secondChunkItem = profileChunkEnvelopes[1][1][0];
143+
const secondHeader = secondChunkItem[0];
144+
const secondPayload = secondChunkItem[1];
145+
expect(secondHeader).toHaveProperty('type', 'profile_chunk');
146+
expect(secondPayload.profile).toBeDefined();
147+
expect(secondPayload.version).toBe('2');
148+
expect(secondPayload.platform).toBe('javascript');
140149
});

dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode/subject.js renamed to dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Sentry.init({
88
integrations: [browserProfilingIntegration()],
99
tracesSampleRate: 1,
1010
profileSessionSampleRate: 1,
11+
profileLifecycle: 'trace',
1112
});
1213

1314
function largeSum(amount = 1000000) {
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event, Profile, ProfileChunkEnvelope } from '@sentry/core';
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import {
5+
getMultipleSentryEnvelopeRequests,
6+
properEnvelopeRequestParser,
7+
properFullEnvelopeRequestParser,
8+
shouldSkipTracingTest,
9+
waitForTransactionRequestOnUrl,
10+
} from '../../../utils/helpers';
11+
12+
sentryTest('does not send profile envelope when document-policy is not set', async ({ page, getLocalTestUrl }) => {
13+
if (shouldSkipTracingTest()) {
14+
// Profiling only works when tracing is enabled
15+
sentryTest.skip();
16+
}
17+
18+
const url = await getLocalTestUrl({ testDir: __dirname });
19+
20+
const req = await waitForTransactionRequestOnUrl(page, url);
21+
const transactionEvent = properEnvelopeRequestParser<Event>(req, 0);
22+
const profileEvent = properEnvelopeRequestParser<Profile>(req, 1);
23+
24+
expect(transactionEvent).toBeDefined();
25+
26+
expect(profileEvent).toBeUndefined();
27+
});
28+
29+
sentryTest(
30+
'sends profile envelope in trace mode (single chunk for overlapping spans)',
31+
async ({ page, getLocalTestUrl, browserName }) => {
32+
if (shouldSkipTracingTest() || browserName !== 'chromium') {
33+
// Profiling only works when tracing is enabled
34+
sentryTest.skip();
35+
}
36+
37+
const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } });
38+
await page.goto(url);
39+
40+
const profileChunkEnvelopePromise = getMultipleSentryEnvelopeRequests<ProfileChunkEnvelope>(
41+
page,
42+
1,
43+
{ envelopeType: 'profile_chunk' },
44+
properFullEnvelopeRequestParser,
45+
);
46+
47+
const profileChunkEnvelopeItem = (await profileChunkEnvelopePromise)[0][1][0];
48+
const envelopeItemHeader = profileChunkEnvelopeItem[0];
49+
const envelopeItemPayload = profileChunkEnvelopeItem[1];
50+
51+
expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk');
52+
53+
expect(envelopeItemPayload.profile).toBeDefined();
54+
expect(envelopeItemPayload.version).toBe('2');
55+
expect(envelopeItemPayload.platform).toBe('javascript');
56+
57+
const profile = envelopeItemPayload.profile;
58+
59+
expect(profile.samples).toBeDefined();
60+
expect(profile.stacks).toBeDefined();
61+
expect(profile.frames).toBeDefined();
62+
expect(profile.thread_metadata).toBeDefined();
63+
64+
// Samples
65+
expect(profile.samples.length).toBeGreaterThanOrEqual(2);
66+
let previousTimestamp = Number.NEGATIVE_INFINITY;
67+
for (const sample of profile.samples) {
68+
expect(typeof sample.stack_id).toBe('number');
69+
expect(sample.stack_id).toBeGreaterThanOrEqual(0);
70+
expect(sample.stack_id).toBeLessThan(profile.stacks.length);
71+
72+
// In trace lifecycle mode, samples carry a numeric timestamp (ms since epoch or similar clock)
73+
expect(typeof (sample as any).timestamp).toBe('number');
74+
const ts = (sample as any).timestamp as number;
75+
expect(Number.isFinite(ts)).toBe(true);
76+
expect(ts).toBeGreaterThan(0);
77+
// Monotonic non-decreasing timestamps
78+
expect(ts).toBeGreaterThanOrEqual(previousTimestamp);
79+
previousTimestamp = ts;
80+
81+
expect(sample.thread_id).toBe('0'); // Should be main thread
82+
}
83+
84+
// Stacks
85+
expect(profile.stacks.length).toBeGreaterThan(0);
86+
for (const stack of profile.stacks) {
87+
expect(Array.isArray(stack)).toBe(true);
88+
for (const frameIndex of stack) {
89+
expect(typeof frameIndex).toBe('number');
90+
expect(frameIndex).toBeGreaterThanOrEqual(0);
91+
expect(frameIndex).toBeLessThan(profile.frames.length);
92+
}
93+
}
94+
95+
// Frames
96+
expect(profile.frames.length).toBeGreaterThan(0);
97+
for (const frame of profile.frames) {
98+
expect(frame).toHaveProperty('function');
99+
expect(frame).toHaveProperty('abs_path');
100+
expect(frame).toHaveProperty('lineno');
101+
expect(frame).toHaveProperty('colno');
102+
103+
expect(typeof frame.function).toBe('string');
104+
expect(typeof frame.abs_path).toBe('string');
105+
expect(typeof frame.lineno).toBe('number');
106+
expect(typeof frame.colno).toBe('number');
107+
}
108+
109+
const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== '');
110+
111+
if ((process.env.PW_BUNDLE || '').endsWith('min')) {
112+
// In bundled mode, function names are minified
113+
expect(functionNames.length).toBeGreaterThan(0);
114+
expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings
115+
} else {
116+
expect(functionNames).toEqual(
117+
expect.arrayContaining([
118+
'_startRootSpan',
119+
'withScope',
120+
'createChildOrRootSpan',
121+
'startSpanManual',
122+
'startJSSelfProfile',
123+
124+
// both functions are captured
125+
'fibonacci',
126+
'largeSum',
127+
]),
128+
);
129+
}
130+
131+
expect(profile.thread_metadata).toHaveProperty('0');
132+
expect(profile.thread_metadata['0']).toHaveProperty('name');
133+
expect(profile.thread_metadata['0'].name).toBe('main');
134+
135+
// Test that profile duration makes sense (should be > 20ms based on test setup)
136+
const startTimeMs = (profile.samples[0] as any).timestamp as number;
137+
const endTimeMs = (profile.samples[profile.samples.length - 1] as any).timestamp as number;
138+
const durationMs = endTimeMs - startTimeMs;
139+
140+
// Should be at least 20ms based on our setTimeout(21) in the test
141+
expect(durationMs).toBeGreaterThan(20);
142+
},
143+
);

0 commit comments

Comments
 (0)