Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
23abc85
skip tests for non-tracing bundles
s1gr1d Aug 27, 2025
9c6ba2e
update types
s1gr1d Aug 26, 2025
36107e8
add profiling integration in test
s1gr1d Aug 27, 2025
27b4026
add integration tests
s1gr1d Aug 28, 2025
c2cb472
add trace lifecycle profiler
s1gr1d Sep 12, 2025
2e8dc1d
fix tests
s1gr1d Sep 12, 2025
3c6ed94
add unit tests
s1gr1d Sep 12, 2025
718be4f
change envelope typing
s1gr1d Sep 15, 2025
9a0606f
put util functions in utils
s1gr1d Sep 15, 2025
ec0fd27
ci(test-matrix): Add logs for `getTestMatrix`
s1gr1d Sep 16, 2025
ae5bae4
Revert "ci(test-matrix): Add logs for `getTestMatrix`"
s1gr1d Sep 16, 2025
dc2e57f
some refactoring
s1gr1d Sep 16, 2025
924e85a
web worker stuff
s1gr1d Sep 17, 2025
e6cf436
fix test
s1gr1d Sep 17, 2025
a301851
refactoring and add tests for required values
s1gr1d Sep 17, 2025
cba6e81
add tests for adding thread data
s1gr1d Sep 18, 2025
5847efa
fix flakey test
s1gr1d Sep 18, 2025
51231a4
add profile validation
s1gr1d Sep 18, 2025
f32e0b5
revert changing command
s1gr1d Sep 18, 2025
ad44d5f
review changes part 1
s1gr1d Sep 25, 2025
cc7461d
add timeout kill-switch for each root span
s1gr1d Sep 30, 2025
e189867
keep same profiler ID over one session
s1gr1d Sep 30, 2025
3e8497e
add size limit
s1gr1d Sep 30, 2025
bbf0794
rename test file
s1gr1d Sep 30, 2025
d9b8388
shorten chunk validation function
s1gr1d Sep 30, 2025
918237e
add catch error messages
s1gr1d Sep 30, 2025
b845030
use processEvent instead of beforeSendEvent
s1gr1d Sep 30, 2025
5e7efe3
fix test flakiness
s1gr1d Sep 30, 2025
5bad618
Merge branch 'refs/heads/develop' into sig/browserProfiling-newAPI
s1gr1d Sep 30, 2025
765f89d
fix type lint
s1gr1d Sep 30, 2025
537b852
bundle size improvment (review comment)
s1gr1d Oct 20, 2025
f4f03c2
Merge branch 'develop' into sig/browserProfiling-newAPI
s1gr1d Oct 20, 2025
fcf5609
remove not needed rule
s1gr1d Oct 20, 2025
4efc1a4
Merge branch 'develop' into sig/browserProfiling-newAPI
s1gr1d Oct 21, 2025
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
7 changes: 7 additions & 0 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ module.exports = [
gzip: true,
limit: '40.7 KB',
},
{
name: '@sentry/browser (incl. Tracing, Profiling)',
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'browserProfilingIntegration'),
gzip: true,
limit: '48 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay)',
path: 'packages/browser/build/npm/esm/index.js',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ function fibonacci(n) {
return fibonacci(n - 1) + fibonacci(n - 2);
}

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

// Timeout to prevent flaky tests. Integration samples every 20ms, if function is too fast it might not get sampled
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,16 @@ sentryTest('sends profile envelope in legacy mode', async ({ page, getLocalTestU
expect(profile.frames.length).toBeGreaterThan(0);
for (const frame of profile.frames) {
expect(frame).toHaveProperty('function');
expect(frame).toHaveProperty('abs_path');
expect(frame).toHaveProperty('lineno');
expect(frame).toHaveProperty('colno');

expect(typeof frame.function).toBe('string');
expect(typeof frame.abs_path).toBe('string');
expect(typeof frame.lineno).toBe('number');
expect(typeof frame.colno).toBe('number');

if (frame.function !== 'fetch' && frame.function !== 'setTimeout') {
expect(frame).toHaveProperty('abs_path');
expect(frame).toHaveProperty('lineno');
expect(frame).toHaveProperty('colno');
expect(typeof frame.abs_path).toBe('string');
expect(typeof frame.lineno).toBe('number');
expect(typeof frame.colno).toBe('number');
}
}

const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== '');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as Sentry from '@sentry/browser';
import { browserProfilingIntegration } from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
integrations: [browserProfilingIntegration()],
tracesSampleRate: 1,
profileSessionSampleRate: 1,
profileLifecycle: 'trace',
});

function largeSum(amount = 1000000) {
let sum = 0;
for (let i = 0; i < amount; i++) {
sum += Math.sqrt(i) * Math.sin(i);
}
}

function fibonacci(n) {
if (n <= 1) {
return n;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}

// Create two NON-overlapping root spans so that the profiler stops and emits a chunk
// after each span (since active root span count returns to 0 between them).
await Sentry.startSpanManual({ name: 'root-fibonacci-1', parentSpan: null, forceTransaction: true }, async span => {
fibonacci(40);
// Ensure we cross the sampling interval to avoid flakes
await new Promise(resolve => setTimeout(resolve, 25));
span.end();
});

// Small delay to ensure the first chunk is collected and sent
await new Promise(r => setTimeout(r, 25));

await Sentry.startSpanManual({ name: 'root-largeSum-2', parentSpan: null, forceTransaction: true }, async span => {
largeSum();
// Ensure we cross the sampling interval to avoid flakes
await new Promise(resolve => setTimeout(resolve, 25));
span.end();
});

const client = Sentry.getClient();
await client?.flush(5000);
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { expect } from '@playwright/test';
import type { ProfileChunkEnvelope } from '@sentry/core';
import { sentryTest } from '../../../utils/fixtures';
import {
countEnvelopes,
getMultipleSentryEnvelopeRequests,
properFullEnvelopeRequestParser,
shouldSkipTracingTest,
} from '../../../utils/helpers';

sentryTest(
'does not send profile envelope when document-policy is not set',
async ({ page, getLocalTestUrl, browserName }) => {
if (shouldSkipTracingTest() || browserName !== 'chromium') {
// Profiling only works when tracing is enabled
sentryTest.skip();
}

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

// Assert that no profile_chunk envelope is sent without policy header
const chunkCount = await countEnvelopes(page, { url, envelopeType: 'profile_chunk', timeout: 1500 });
expect(chunkCount).toBe(0);
},
);

sentryTest(
'sends profile_chunk envelopes in trace mode (multiple chunks)',
async ({ page, getLocalTestUrl, browserName }) => {
if (shouldSkipTracingTest() || browserName !== 'chromium') {
// Profiling only works when tracing is enabled
sentryTest.skip();
}

const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } });

// Expect at least 2 chunks because subject creates two separate root spans,
// causing the profiler to stop and emit a chunk after each root span ends.
const profileChunkEnvelopes = await getMultipleSentryEnvelopeRequests<ProfileChunkEnvelope>(
page,
2,
{ url, envelopeType: 'profile_chunk', timeout: 5000 },
properFullEnvelopeRequestParser,
);

expect(profileChunkEnvelopes.length).toBeGreaterThanOrEqual(2);

// Validate the first chunk thoroughly
const profileChunkEnvelopeItem = profileChunkEnvelopes[0][1][0];
const envelopeItemHeader = profileChunkEnvelopeItem[0];
const envelopeItemPayload1 = profileChunkEnvelopeItem[1];

expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk');

expect(envelopeItemPayload1.profile).toBeDefined();
expect(envelopeItemPayload1.version).toBe('2');
expect(envelopeItemPayload1.platform).toBe('javascript');

// Required profile metadata (Sample Format V2)
expect(typeof envelopeItemPayload1.profiler_id).toBe('string');
expect(envelopeItemPayload1.profiler_id).toMatch(/^[a-f0-9]{32}$/);
expect(typeof envelopeItemPayload1.chunk_id).toBe('string');
expect(envelopeItemPayload1.chunk_id).toMatch(/^[a-f0-9]{32}$/);
expect(envelopeItemPayload1.client_sdk).toBeDefined();
expect(typeof envelopeItemPayload1.client_sdk.name).toBe('string');
expect(typeof envelopeItemPayload1.client_sdk.version).toBe('string');
expect(typeof envelopeItemPayload1.release).toBe('string');
expect(envelopeItemPayload1.debug_meta).toBeDefined();
expect(Array.isArray(envelopeItemPayload1?.debug_meta?.images)).toBe(true);

const profile1 = envelopeItemPayload1.profile;

expect(profile1.samples).toBeDefined();
expect(profile1.stacks).toBeDefined();
expect(profile1.frames).toBeDefined();
expect(profile1.thread_metadata).toBeDefined();

// Samples
expect(profile1.samples.length).toBeGreaterThanOrEqual(2);
let previousTimestamp = Number.NEGATIVE_INFINITY;
for (const sample of profile1.samples) {
expect(typeof sample.stack_id).toBe('number');
expect(sample.stack_id).toBeGreaterThanOrEqual(0);
expect(sample.stack_id).toBeLessThan(profile1.stacks.length);

// In trace lifecycle mode, samples carry a numeric timestamp (ms since epoch or similar clock)
expect(typeof (sample as any).timestamp).toBe('number');
const ts = (sample as any).timestamp as number;
expect(Number.isFinite(ts)).toBe(true);
expect(ts).toBeGreaterThan(0);
// Monotonic non-decreasing timestamps
expect(ts).toBeGreaterThanOrEqual(previousTimestamp);
previousTimestamp = ts;

expect(sample.thread_id).toBe('0'); // Should be main thread
}

// Stacks
expect(profile1.stacks.length).toBeGreaterThan(0);
for (const stack of profile1.stacks) {
expect(Array.isArray(stack)).toBe(true);
for (const frameIndex of stack) {
expect(typeof frameIndex).toBe('number');
expect(frameIndex).toBeGreaterThanOrEqual(0);
expect(frameIndex).toBeLessThan(profile1.frames.length);
}
}

// Frames
expect(profile1.frames.length).toBeGreaterThan(0);
for (const frame of profile1.frames) {
expect(frame).toHaveProperty('function');
expect(typeof frame.function).toBe('string');

if (frame.function !== 'fetch' && frame.function !== 'setTimeout') {
expect(frame).toHaveProperty('abs_path');
expect(frame).toHaveProperty('lineno');
expect(frame).toHaveProperty('colno');
expect(typeof frame.abs_path).toBe('string');
expect(typeof frame.lineno).toBe('number');
expect(typeof frame.colno).toBe('number');
}
}

const functionNames = profile1.frames.map(frame => frame.function).filter(name => name !== '');

if ((process.env.PW_BUNDLE || '').endsWith('min')) {
// In bundled mode, function names are minified
expect(functionNames.length).toBeGreaterThan(0);
expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings
} else {
expect(functionNames).toEqual(
expect.arrayContaining([
'_startRootSpan',
'withScope',
'createChildOrRootSpan',
'startSpanManual',
'startJSSelfProfile',

// first function is captured (other one is in other chunk)
'fibonacci',
]),
);
}

expect(profile1.thread_metadata).toHaveProperty('0');
expect(profile1.thread_metadata['0']).toHaveProperty('name');
expect(profile1.thread_metadata['0'].name).toBe('main');

// Test that profile duration makes sense (should be > 20ms based on test setup)
const startTimeSec = (profile1.samples[0] as any).timestamp as number;
const endTimeSec = (profile1.samples[profile1.samples.length - 1] as any).timestamp as number;
const durationSec = endTimeSec - startTimeSec;

// Should be at least 20ms based on our setTimeout(21) in the test
expect(durationSec).toBeGreaterThan(0.2);
Comment on lines +151 to +156
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: instead of relying on an inferred metric, you could just check if sample count > 1

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as other comment:

I'm checking the samples count: expect(profile1.samples.length).toBeGreaterThanOrEqual(2);
However, there are more than two samples because the fibonacci function is called a couple of times. So I added this additional check for the duration.


// === PROFILE CHUNK 2 ===

const profileChunkEnvelopeItem2 = profileChunkEnvelopes[1][1][0];
const envelopeItemHeader2 = profileChunkEnvelopeItem2[0];
const envelopeItemPayload2 = profileChunkEnvelopeItem2[1];

// Basic sanity on the second chunk: has correct envelope type and structure
expect(envelopeItemHeader2).toHaveProperty('type', 'profile_chunk');
expect(envelopeItemPayload2.profile).toBeDefined();
expect(envelopeItemPayload2.version).toBe('2');
expect(envelopeItemPayload2.platform).toBe('javascript');

// Required profile metadata (Sample Format V2)
// https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/
expect(typeof envelopeItemPayload2.profiler_id).toBe('string');
expect(envelopeItemPayload2.profiler_id).toMatch(/^[a-f0-9]{32}$/);
expect(typeof envelopeItemPayload2.chunk_id).toBe('string');
expect(envelopeItemPayload2.chunk_id).toMatch(/^[a-f0-9]{32}$/);
expect(envelopeItemPayload2.client_sdk).toBeDefined();
expect(typeof envelopeItemPayload2.client_sdk.name).toBe('string');
expect(typeof envelopeItemPayload2.client_sdk.version).toBe('string');
expect(typeof envelopeItemPayload2.release).toBe('string');
expect(envelopeItemPayload2.debug_meta).toBeDefined();
expect(Array.isArray(envelopeItemPayload2?.debug_meta?.images)).toBe(true);

const profile2 = envelopeItemPayload2.profile;

const functionNames2 = profile2.frames.map(frame => frame.function).filter(name => name !== '');

if ((process.env.PW_BUNDLE || '').endsWith('min')) {
// In bundled mode, function names are minified
expect(functionNames2.length).toBeGreaterThan(0);
expect((functionNames2 as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings
} else {
expect(functionNames2).toEqual(
expect.arrayContaining([
'_startRootSpan',
'withScope',
'createChildOrRootSpan',
'startSpanManual',
'startJSSelfProfile',

// second function is captured (other one is in other chunk)
'largeSum',
]),
);
}
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as Sentry from '@sentry/browser';
import { browserProfilingIntegration } from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
integrations: [browserProfilingIntegration()],
tracesSampleRate: 1,
profileSessionSampleRate: 1,
profileLifecycle: 'trace',
});

function largeSum(amount = 1000000) {
let sum = 0;
for (let i = 0; i < amount; i++) {
sum += Math.sqrt(i) * Math.sin(i);
}
}

function fibonacci(n) {
if (n <= 1) {
return n;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}

let firstSpan;

Sentry.startSpanManual({ name: 'root-largeSum-1', parentSpan: null, forceTransaction: true }, span => {
largeSum();
firstSpan = span;
});

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

Sentry.startSpan({ name: 'child-fibonacci', parentSpan: span }, childSpan => {
console.log('child span');
});

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my comment about relying on sample count above might mitigate this

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm checking the samples count: expect(profile1.samples.length).toBeGreaterThanOrEqual(2);
However, there are more than two samples because the fibonacci function is called a couple of times. So I added this additional check for the duration.

await new Promise(resolve => setTimeout(resolve, 21));
span.end();
});

await new Promise(r => setTimeout(r, 21));

firstSpan.end();

const client = Sentry.getClient();
await client?.flush(5000);
Loading