From 23abc854cec109aba318b3050d83b80cc06d237b Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Wed, 27 Aug 2025 13:44:26 +0200 Subject: [PATCH 01/31] skip tests for non-tracing bundles --- .../suites/profiling/legacyMode/test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts index 35f4e17bec0a..c34e8a25584c 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts @@ -15,7 +15,12 @@ sentryTest( sentryTest.skip(); } - const url = await getLocalTestUrl({ testDir: __dirname }); + if (shouldSkipTracingTest()) { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); const req = await waitForTransactionRequestOnUrl(page, url); const transactionEvent = properEnvelopeRequestParser(req, 0); From 9c6ba2e4cfd93161b3bc2aa82d8b45743f7fe102 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 26 Aug 2025 10:01:26 +0200 Subject: [PATCH 02/31] update types --- .../core/src/types-hoist/browseroptions.ts | 22 +++++++++++++++++++ packages/node/src/types.ts | 13 ++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/core/src/types-hoist/browseroptions.ts b/packages/core/src/types-hoist/browseroptions.ts index 1df40c6fd614..20bd3816eed3 100644 --- a/packages/core/src/types-hoist/browseroptions.ts +++ b/packages/core/src/types-hoist/browseroptions.ts @@ -18,9 +18,31 @@ export type BrowserClientReplayOptions = { }; export type BrowserClientProfilingOptions = { + // todo: add deprecation warning for profilesSampleRate: @deprecated Use `profileSessionSampleRate` and `profileLifecycle` instead. /** * The sample rate for profiling * 1.0 will profile all transactions and 0 will profile none. */ profilesSampleRate?: number; + + /** + * Sets profiling session sample rate for the entire profiling session. + * + * A profiling session corresponds to a user session, so this rate determines what percentage of user sessions will have profiling enabled. + * + * @default 0 + */ + profileSessionSampleRate?: number; + + /** + * Set the lifecycle mode of the profiler. + * - **manual**: The profiler will be manually started and stopped via `startProfiler`/`stopProfiler`. + * If a session is sampled, is dependent on the `profileSessionSampleRate`. + * - **trace**: The profiler will be automatically started when a root span exists and stopped when there are no + * more sampled root spans. Whether a session is sampled, is dependent on the `profileSessionSampleRate` and the + * existing sampling configuration for tracing (`tracesSampleRate`/`tracesSampler`). + * + * @default 'manual' + */ + profileLifecycle?: 'manual' | 'trace'; }; diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 3d3463d0b5cf..1f84b69a9f28 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -46,16 +46,19 @@ export interface BaseNodeOptions { profilesSampler?: (samplingContext: SamplingContext) => number | boolean; /** - * Sets profiling session sample rate - only evaluated once per SDK initialization. + * Sets profiling session sample rate for the entire profiling session (evaluated once per SDK initialization). + * * @default 0 */ profileSessionSampleRate?: number; /** - * Set the lifecycle of the profiler. - * - * - `manual`: The profiler will be manually started and stopped. - * - `trace`: The profiler will be automatically started when when a span is sampled and stopped when there are no more sampled spans. + * Set the lifecycle mode of the profiler. + * - **manual**: The profiler will be manually started and stopped via `startProfiler`/`stopProfiler`. + * If a session is sampled, is dependent on the `profileSessionSampleRate`. + * - **trace**: The profiler will be automatically started when a root span exists and stopped when there are no + * more sampled root spans. Whether a session is sampled, is dependent on the `profileSessionSampleRate` and the + * existing sampling configuration for tracing (`tracesSampleRate`/`tracesSampler`). * * @default 'manual' */ From 36107e8b5b63dcecd2f8af8d42b216675bea3805 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Wed, 27 Aug 2025 16:31:24 +0200 Subject: [PATCH 03/31] add profiling integration in test --- .../suites/profiling/legacyMode/test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts index c34e8a25584c..fee5f216d410 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts @@ -16,11 +16,11 @@ sentryTest( } if (shouldSkipTracingTest()) { - // Profiling only works when tracing is enabled - sentryTest.skip(); - } + // Profiling only works when tracing is enabled + sentryTest.skip(); + } - const url = await getLocalTestUrl({ testDir: __dirname }); + const url = await getLocalTestUrl({ testDir: __dirname }); const req = await waitForTransactionRequestOnUrl(page, url); const transactionEvent = properEnvelopeRequestParser(req, 0); From 27b402675381d7a2662cdbf6a9eb62424e46c64d Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Thu, 28 Aug 2025 11:12:29 +0200 Subject: [PATCH 04/31] add integration tests --- .../suites/profiling/legacyMode/subject.js | 2 +- .../profiling/traceLifecycleMode/subject.js | 48 ++++++ .../profiling/traceLifecycleMode/test.ts | 140 ++++++++++++++++++ 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode/test.ts diff --git a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js index 230e9ee1fb9e..aad9fd2a764c 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js +++ b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js @@ -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 diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode/subject.js new file mode 100644 index 000000000000..0a5a9b7a8e5c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode/subject.js @@ -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, +}); + +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); + + // Timeout to prevent flaky tests. Integration samples every 20ms, if function is too fast it might not get sampled + await new Promise(resolve => setTimeout(resolve, 21)); + span.end(); +}); + +await new Promise(r => setTimeout(r, 21)); + +firstSpan.end(); + +// Ensure envelope flush +const client = Sentry.getClient(); +await client?.flush(5000); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode/test.ts new file mode 100644 index 000000000000..7b9942063a63 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode/test.ts @@ -0,0 +1,140 @@ +import { expect } from '@playwright/test'; +import type { Event, Profile, ProfileChunkEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + getMultipleSentryEnvelopeRequests, + properEnvelopeRequestParser, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, + waitForTransactionRequestOnUrl, +} from '../../../utils/helpers'; + +sentryTest('does not send profile envelope when document-policy is not set', async ({ page, getLocalTestUrl }) => { + if (shouldSkipTracingTest()) { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const req = await waitForTransactionRequestOnUrl(page, url); + const transactionEvent = properEnvelopeRequestParser(req, 0); + const profileEvent = properEnvelopeRequestParser(req, 1); + + expect(transactionEvent).toBeDefined(); + + expect(profileEvent).toBeUndefined(); +}); + +sentryTest('sends profile envelope in trace mode', 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' } }); + await page.goto(url); + + const profileChunkEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'profile_chunk' }, + properFullEnvelopeRequestParser, + ); + + const profileChunkEnvelopeItem = (await profileChunkEnvelopePromise)[0][1][0]; + const envelopeItemHeader = profileChunkEnvelopeItem[0]; + const envelopeItemPayload = profileChunkEnvelopeItem[1]; + + expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); + + expect(envelopeItemPayload.profile).toBeDefined(); + expect(envelopeItemPayload.version).toBe('2'); + expect(envelopeItemPayload.platform).toBe('javascript'); + + const profile = envelopeItemPayload.profile; + + expect(profile.samples).toBeDefined(); + expect(profile.stacks).toBeDefined(); + expect(profile.frames).toBeDefined(); + expect(profile.thread_metadata).toBeDefined(); + + // Samples + expect(profile.samples.length).toBeGreaterThanOrEqual(2); + let previousTimestamp = Number.NEGATIVE_INFINITY; + for (const sample of profile.samples) { + expect(typeof sample.stack_id).toBe('number'); + expect(sample.stack_id).toBeGreaterThanOrEqual(0); + expect(sample.stack_id).toBeLessThan(profile.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(profile.stacks.length).toBeGreaterThan(0); + for (const stack of profile.stacks) { + expect(Array.isArray(stack)).toBe(true); + for (const frameIndex of stack) { + expect(typeof frameIndex).toBe('number'); + expect(frameIndex).toBeGreaterThanOrEqual(0); + expect(frameIndex).toBeLessThan(profile.frames.length); + } + } + + // Frames + 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'); + } + + const functionNames = profile.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', + + // both functions are captured + 'fibonacci', + 'largeSum', + ]), + ); + } + + expect(profile.thread_metadata).toHaveProperty('0'); + expect(profile.thread_metadata['0']).toHaveProperty('name'); + expect(profile.thread_metadata['0'].name).toBe('main'); + + // Test that profile duration makes sense (should be > 20ms based on test setup) + const startTimeMs = (profile.samples[0] as any).timestamp as number; + const endTimeMs = (profile.samples[profile.samples.length - 1] as any).timestamp as number; + const durationMs = endTimeMs - startTimeMs; + + // Should be at least 20ms based on our setTimeout(21) in the test + expect(durationMs).toBeGreaterThan(20); +}); From c2cb472fd577eff0557ed29a08fdac5e291e86f4 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Fri, 12 Sep 2025 12:53:10 +0200 Subject: [PATCH 05/31] add trace lifecycle profiler --- .../subject.js | 49 +++ .../test.ts | 39 +- .../subject.js | 1 + .../test.ts | 143 ++++++++ packages/browser/src/profiling/integration.ts | 154 +++++--- .../session/traceLifecycleProfiler.ts | 339 ++++++++++++++++++ .../src/profiling/startProfileForSpan.ts | 3 +- packages/browser/src/profiling/utils.ts | 58 ++- .../test/profiling/integration.test.ts | 43 +++ .../test/profiling/traceLifecycle.test.ts | 185 ++++++++++ 10 files changed, 942 insertions(+), 72 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js rename dev-packages/browser-integration-tests/suites/profiling/{traceLifecycleMode => traceLifecycleMode_multiple-chunks}/test.ts (77%) rename dev-packages/browser-integration-tests/suites/profiling/{traceLifecycleMode => traceLifecycleMode_overlapping-spans}/subject.js (97%) create mode 100644 dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts create mode 100644 packages/browser/src/profiling/session/traceLifecycleProfiler.ts create mode 100644 packages/browser/test/profiling/traceLifecycle.test.ts diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js new file mode 100644 index 000000000000..01db8102412a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js @@ -0,0 +1,49 @@ +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(); +}); + +// Ensure envelope flush +const client = Sentry.getClient(); +await client?.flush(5000); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts similarity index 77% rename from dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode/test.ts rename to dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts index 7b9942063a63..4cc2fd4990a3 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts @@ -1,12 +1,11 @@ import { expect } from '@playwright/test'; -import type { Event, Profile, ProfileChunkEnvelope } from '@sentry/core'; +import type { ProfileChunkEnvelope } from '@sentry/core'; import { sentryTest } from '../../../utils/fixtures'; import { + countEnvelopes, getMultipleSentryEnvelopeRequests, - properEnvelopeRequestParser, properFullEnvelopeRequestParser, shouldSkipTracingTest, - waitForTransactionRequestOnUrl, } from '../../../utils/helpers'; 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 const url = await getLocalTestUrl({ testDir: __dirname }); - const req = await waitForTransactionRequestOnUrl(page, url); - const transactionEvent = properEnvelopeRequestParser(req, 0); - const profileEvent = properEnvelopeRequestParser(req, 1); - - expect(transactionEvent).toBeDefined(); - - expect(profileEvent).toBeUndefined(); + // 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 envelope in trace mode', async ({ page, getLocalTestUrl, browserName }) => { +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(); @@ -35,14 +30,19 @@ sentryTest('sends profile envelope in trace mode', async ({ page, getLocalTestUr const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); await page.goto(url); - const profileChunkEnvelopePromise = getMultipleSentryEnvelopeRequests( + // 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( page, - 1, - { envelopeType: 'profile_chunk' }, + 2, + { envelopeType: 'profile_chunk', timeout: 5000 }, properFullEnvelopeRequestParser, ); - const profileChunkEnvelopeItem = (await profileChunkEnvelopePromise)[0][1][0]; + expect(profileChunkEnvelopes.length).toBeGreaterThanOrEqual(2); + + // Validate the first chunk thoroughly + const profileChunkEnvelopeItem = profileChunkEnvelopes[0][1][0]; const envelopeItemHeader = profileChunkEnvelopeItem[0]; const envelopeItemPayload = profileChunkEnvelopeItem[1]; @@ -137,4 +137,13 @@ sentryTest('sends profile envelope in trace mode', async ({ page, getLocalTestUr // Should be at least 20ms based on our setTimeout(21) in the test expect(durationMs).toBeGreaterThan(20); + + // Basic sanity on the second chunk: has correct envelope type and structure + const secondChunkItem = profileChunkEnvelopes[1][1][0]; + const secondHeader = secondChunkItem[0]; + const secondPayload = secondChunkItem[1]; + expect(secondHeader).toHaveProperty('type', 'profile_chunk'); + expect(secondPayload.profile).toBeDefined(); + expect(secondPayload.version).toBe('2'); + expect(secondPayload.platform).toBe('javascript'); }); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js similarity index 97% rename from dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode/subject.js rename to dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js index 0a5a9b7a8e5c..4d1db91c9b0b 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode/subject.js +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js @@ -8,6 +8,7 @@ Sentry.init({ integrations: [browserProfilingIntegration()], tracesSampleRate: 1, profileSessionSampleRate: 1, + profileLifecycle: 'trace', }); function largeSum(amount = 1000000) { diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts new file mode 100644 index 000000000000..c5ee7ccb3cf1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts @@ -0,0 +1,143 @@ +import { expect } from '@playwright/test'; +import type { Event, Profile, ProfileChunkEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + getMultipleSentryEnvelopeRequests, + properEnvelopeRequestParser, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, + waitForTransactionRequestOnUrl, +} from '../../../utils/helpers'; + +sentryTest('does not send profile envelope when document-policy is not set', async ({ page, getLocalTestUrl }) => { + if (shouldSkipTracingTest()) { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const req = await waitForTransactionRequestOnUrl(page, url); + const transactionEvent = properEnvelopeRequestParser(req, 0); + const profileEvent = properEnvelopeRequestParser(req, 1); + + expect(transactionEvent).toBeDefined(); + + expect(profileEvent).toBeUndefined(); +}); + +sentryTest( + 'sends profile envelope in trace mode (single chunk for overlapping spans)', + 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' } }); + await page.goto(url); + + const profileChunkEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'profile_chunk' }, + properFullEnvelopeRequestParser, + ); + + const profileChunkEnvelopeItem = (await profileChunkEnvelopePromise)[0][1][0]; + const envelopeItemHeader = profileChunkEnvelopeItem[0]; + const envelopeItemPayload = profileChunkEnvelopeItem[1]; + + expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); + + expect(envelopeItemPayload.profile).toBeDefined(); + expect(envelopeItemPayload.version).toBe('2'); + expect(envelopeItemPayload.platform).toBe('javascript'); + + const profile = envelopeItemPayload.profile; + + expect(profile.samples).toBeDefined(); + expect(profile.stacks).toBeDefined(); + expect(profile.frames).toBeDefined(); + expect(profile.thread_metadata).toBeDefined(); + + // Samples + expect(profile.samples.length).toBeGreaterThanOrEqual(2); + let previousTimestamp = Number.NEGATIVE_INFINITY; + for (const sample of profile.samples) { + expect(typeof sample.stack_id).toBe('number'); + expect(sample.stack_id).toBeGreaterThanOrEqual(0); + expect(sample.stack_id).toBeLessThan(profile.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(profile.stacks.length).toBeGreaterThan(0); + for (const stack of profile.stacks) { + expect(Array.isArray(stack)).toBe(true); + for (const frameIndex of stack) { + expect(typeof frameIndex).toBe('number'); + expect(frameIndex).toBeGreaterThanOrEqual(0); + expect(frameIndex).toBeLessThan(profile.frames.length); + } + } + + // Frames + 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'); + } + + const functionNames = profile.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', + + // both functions are captured + 'fibonacci', + 'largeSum', + ]), + ); + } + + expect(profile.thread_metadata).toHaveProperty('0'); + expect(profile.thread_metadata['0']).toHaveProperty('name'); + expect(profile.thread_metadata['0'].name).toBe('main'); + + // Test that profile duration makes sense (should be > 20ms based on test setup) + const startTimeMs = (profile.samples[0] as any).timestamp as number; + const endTimeMs = (profile.samples[profile.samples.length - 1] as any).timestamp as number; + const durationMs = endTimeMs - startTimeMs; + + // Should be at least 20ms based on our setTimeout(21) in the test + expect(durationMs).toBeGreaterThan(20); + }, +); diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 7ad77d8920e5..3ef3bfc50644 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -1,6 +1,9 @@ import type { EventEnvelope, IntegrationFn, Profile, Span } from '@sentry/core'; -import { debug, defineIntegration, getActiveSpan, getRootSpan } from '@sentry/core'; +import { debug, defineIntegration, getActiveSpan, getRootSpan, hasSpansEnabled } from '@sentry/core'; +import type { BrowserOptions } from '../client'; import { DEBUG_BUILD } from '../debug-build'; +import { WINDOW } from '../helpers'; +import { BrowserTraceLifecycleProfiler } from './session/traceLifecycleProfiler'; import { startProfileForSpan } from './startProfileForSpan'; import type { ProfiledEvent } from './utils'; import { @@ -8,8 +11,10 @@ import { createProfilingEvent, findProfiledTransactionsFromEnvelope, getActiveProfilesCount, + hasLegacyProfiling, isAutomatedPageLoadSpan, - shouldProfileSpan, + shouldProfileSession, + shouldProfileSpanLegacy, takeProfileFromGlobalCache, } from './utils'; @@ -19,73 +24,122 @@ const _browserProfilingIntegration = (() => { return { name: INTEGRATION_NAME, setup(client) { - const activeSpan = getActiveSpan(); - const rootSpan = activeSpan && getRootSpan(activeSpan); + const options = client.getOptions() as BrowserOptions; - if (rootSpan && isAutomatedPageLoadSpan(rootSpan)) { - if (shouldProfileSpan(rootSpan)) { - startProfileForSpan(rootSpan); - } + if (options && !hasLegacyProfiling(options) && !options.profileLifecycle) { + options.profileLifecycle = 'trace'; } - client.on('spanStart', (span: Span) => { - if (span === getRootSpan(span) && shouldProfileSpan(span)) { - startProfileForSpan(span); - } - }); + if (!options || (hasLegacyProfiling(options) && !options.profilesSampleRate)) { + DEBUG_BUILD && debug.log('[Profiling] Profiling disabled, no profiling options found.'); + return; + } - client.on('beforeEnvelope', (envelope): void => { - // if not profiles are in queue, there is nothing to add to the envelope. - if (!getActiveProfilesCount()) { - return; - } + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan && getRootSpan(activeSpan); + + // UI PROFILING (Profiling V2) + if (!hasLegacyProfiling(options)) { + const lifecycleMode = options.profileLifecycle; - const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); - if (!profiledTransactionEvents.length) { - return; + if (lifecycleMode === 'trace' && !hasSpansEnabled(options)) { + DEBUG_BUILD && + debug.warn( + "[Profiling] `profileLifecycle` is 'trace' but tracing is disabled. Set a `tracesSampleRate` or `tracesSampler` to enable span tracing.", + ); } - const profilesToAddToEnvelope: Profile[] = []; + if (lifecycleMode === 'trace' && hasSpansEnabled(options)) { + const sessionSampled = shouldProfileSession(options); + if (!sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled. Skipping lifecycle profiler initialization.'); + return; + } + + const traceLifecycleProfiler = new BrowserTraceLifecycleProfiler(); + traceLifecycleProfiler.initialize(client, sessionSampled); - for (const profiledTransaction of profiledTransactionEvents) { - const context = profiledTransaction?.contexts; - const profile_id = context?.profile?.['profile_id']; - const start_timestamp = context?.profile?.['start_timestamp']; + // If there is an active, sampled root span already, notify the profiler + if (rootSpan) { + traceLifecycleProfiler.notifyRootSpanActive(rootSpan); + } - if (typeof profile_id !== 'string') { - DEBUG_BUILD && debug.log('[Profiling] cannot find profile for a span without a profile context'); - continue; + // In case rootSpan is created slightly after setup -> schedule microtask to re-check and notify. + WINDOW.setTimeout(() => { + const laterActiveSpan = getActiveSpan(); + const laterRootSpan = laterActiveSpan && getRootSpan(laterActiveSpan); + if (laterRootSpan) { + traceLifecycleProfiler.notifyRootSpanActive(laterRootSpan); + } + }, 0); + } + } else { + // LEGACY PROFILING (v1) + if (rootSpan && isAutomatedPageLoadSpan(rootSpan)) { + if (shouldProfileSpanLegacy(rootSpan)) { + startProfileForSpan(rootSpan); } + } - if (!profile_id) { - DEBUG_BUILD && debug.log('[Profiling] cannot find profile for a span without a profile context'); - continue; + client.on('spanStart', (span: Span) => { + if (span === getRootSpan(span) && shouldProfileSpanLegacy(span)) { + startProfileForSpan(span); } + }); - // Remove the profile from the span context before sending, relay will take care of the rest. - if (context?.profile) { - delete context.profile; + client.on('beforeEnvelope', (envelope): void => { + // if not profiles are in queue, there is nothing to add to the envelope. + if (!getActiveProfilesCount()) { + return; } - const profile = takeProfileFromGlobalCache(profile_id); - if (!profile) { - DEBUG_BUILD && debug.log(`[Profiling] Could not retrieve profile for span: ${profile_id}`); - continue; + const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); + if (!profiledTransactionEvents.length) { + return; } - const profileEvent = createProfilingEvent( - profile_id, - start_timestamp as number | undefined, - profile, - profiledTransaction as ProfiledEvent, - ); - if (profileEvent) { - profilesToAddToEnvelope.push(profileEvent); + const profilesToAddToEnvelope: Profile[] = []; + + for (const profiledTransaction of profiledTransactionEvents) { + const context = profiledTransaction?.contexts; + const profile_id = context?.profile?.['profile_id']; + const start_timestamp = context?.profile?.['start_timestamp']; + + if (typeof profile_id !== 'string') { + DEBUG_BUILD && debug.log('[Profiling] cannot find profile for a span without a profile context'); + continue; + } + + if (!profile_id) { + DEBUG_BUILD && debug.log('[Profiling] cannot find profile for a span without a profile context'); + continue; + } + + // Remove the profile from the span context before sending, relay will take care of the rest. + if (context?.profile) { + delete context.profile; + } + + const profile = takeProfileFromGlobalCache(profile_id); + if (!profile) { + DEBUG_BUILD && debug.log(`[Profiling] Could not retrieve profile for span: ${profile_id}`); + continue; + } + + const profileEvent = createProfilingEvent( + profile_id, + start_timestamp as number | undefined, + profile, + profiledTransaction as ProfiledEvent, + ); + if (profileEvent) { + profilesToAddToEnvelope.push(profileEvent); + } } - } - addProfilesToEnvelope(envelope as EventEnvelope, profilesToAddToEnvelope); - }); + addProfilesToEnvelope(envelope as EventEnvelope, profilesToAddToEnvelope); + }); + } }, }; }) satisfies IntegrationFn; diff --git a/packages/browser/src/profiling/session/traceLifecycleProfiler.ts b/packages/browser/src/profiling/session/traceLifecycleProfiler.ts new file mode 100644 index 000000000000..ff8fea07f114 --- /dev/null +++ b/packages/browser/src/profiling/session/traceLifecycleProfiler.ts @@ -0,0 +1,339 @@ +import type { Client, ContinuousThreadCpuProfile, ProfileChunk, Span } from '@sentry/core'; +import { + type ProfileChunkEnvelope, + createEnvelope, + debug, + getRootSpan, + getSdkMetadataForEnvelopeHeader, + spanToJSON, + uuid4, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { JSSelfProfiler } from '../jsSelfProfiling'; +import { applyDebugMetadata as applyDebugImages, startJSSelfProfile } from '../utils'; + +const CHUNK_INTERVAL_MS = 60_000; + +/** + * 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. + */ +export class BrowserTraceLifecycleProfiler { + private _client: Client | undefined; + private _profiler: JSSelfProfiler | undefined; + private _chunkTimer: ReturnType | undefined; + private _activeRootSpanCount: number; + private _rootSpanIds: Set; + private _profilerId: string | undefined; + private _isRunning: boolean; + private _sessionSampled: boolean; + + public constructor() { + this._client = undefined; + this._profiler = undefined; + this._chunkTimer = undefined; + this._activeRootSpanCount = 0; + this._rootSpanIds = new Set(); + this._profilerId = undefined; + this._isRunning = false; + this._sessionSampled = false; + } + + /** + * Initialize the profiler with client and session sampling decision computed by the integration. + */ + public initialize(client: Client, sessionSampled: boolean): void { + DEBUG_BUILD && debug.log("[Profiling] Initializing profiler (lifecycle='trace')."); + + this._client = client; + this._sessionSampled = sessionSampled; + + client.on('spanStart', span => { + if (span !== getRootSpan(span)) { + return; + } + if (!this._sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled because of negative sampling decision.'); + return; + } + // Only count sampled root spans + if (!span.isRecording()) { + DEBUG_BUILD && debug.log('[Profiling] Discarding profile because root span was not sampled.'); + return; + } + + const spanId = spanToJSON(span)?.span_id as string | undefined; + if (!spanId) { + return; + } + if (this._rootSpanIds.has(spanId)) { + return; + } + this._rootSpanIds.add(spanId); + + const wasZero = this._activeRootSpanCount === 0; + this._activeRootSpanCount++; // Increment before eventually starting the profiler + DEBUG_BUILD && debug.log('[Profiling] Root span started. Active root spans:', this._activeRootSpanCount); + if (wasZero) { + this.start(); + } + }); + + client.on('spanEnd', span => { + if (!this._sessionSampled) { + return; + } + const spanId = spanToJSON(span)?.span_id as string | undefined; + if (!spanId || !this._rootSpanIds.has(spanId)) { + return; + } + + this._rootSpanIds.delete(spanId); + this._activeRootSpanCount = Math.max(0, this._activeRootSpanCount - 1); + DEBUG_BUILD && debug.log('[Profiling] Root span ended. Active root spans:', this._activeRootSpanCount); + if (this._activeRootSpanCount === 0) { + this._collectCurrentChunk().catch(() => { + /* no catch */ + }); + this.stop(); + } + }); + } + + /** + * Handle an already-active root span at integration setup time. + */ + public notifyRootSpanActive(span: Span): void { + if (!this._sessionSampled) { + return; + } + if (span !== getRootSpan(span)) { + return; + } + const spanId = spanToJSON(span)?.span_id; + if (!spanId || this._rootSpanIds.has(spanId)) { + return; + } + + const wasZero = this._activeRootSpanCount === 0; + + this._rootSpanIds.add(spanId); + this._activeRootSpanCount++; + DEBUG_BUILD && + debug.log( + '[Profiling] Detected already active root span during setup. Active root spans:', + this._activeRootSpanCount, + ); + + if (wasZero) { + this.start(); + } + } + + /** + * Start profiling if not already running. + */ + public start(): void { + if (this._isRunning) { + return; + } + + this._isRunning = true; + if (!this._profilerId) { + this._profilerId = uuid4(); + } + + DEBUG_BUILD && debug.log('[Profiling] Started profiling with profile ID:', this._profilerId); + + this._startProfilerInstance(); + this._scheduleNextChunk(); + } + + /** + * Stop profiling; final chunk will be collected and sent. + */ + public stop(): void { + this._isRunning = false; + if (this._chunkTimer) { + clearTimeout(this._chunkTimer); + this._chunkTimer = undefined; + } + + this._collectCurrentChunk().catch(() => { + /* no catch */ + }); + // Reset profiler id so a new continuous session gets a fresh id + this._profilerId = undefined; + } + + /** + * Start a profiler instance if needed. + */ + private _startProfilerInstance(): void { + if (this._profiler?.stopped === false) { + return; + } + const profiler = startJSSelfProfile(); + if (!profiler) { + DEBUG_BUILD && debug.log('[Profiling] Failed to start JS self profiler in trace lifecycle.'); + return; + } + this._profiler = profiler; + } + + /** + * Schedule the next 60s chunk while running. + * Each tick collects a chunk and restarts the profiler. + * A chunk should be closed when there are no active root spans anymore OR when the maximum chunk interval is reached. + */ + private _scheduleNextChunk(): void { + if (!this._isRunning) { + return; + } + + this._chunkTimer = setTimeout(() => { + this._collectCurrentChunk().catch(() => { + /* no catch */ + }); + + if (this._isRunning) { + this._startProfilerInstance(); + this._scheduleNextChunk(); + } + }, CHUNK_INTERVAL_MS); + } + + /** + * Stop the current profiler, convert and send a profile chunk. + */ + private async _collectCurrentChunk(): Promise { + const profiler = this._profiler; + this._profiler = undefined; + if (!profiler) { + return; + } + + try { + const profile = await profiler.stop(); + const continuous = this._toContinuousProfile(profile); + const chunk: ProfileChunk = this._makeProfileChunk(continuous); + + this._sendProfileChunk(chunk); + DEBUG_BUILD && debug.log('[Profiling] Collected browser profile chunk.'); + } catch (e) { + DEBUG_BUILD && debug.log('[Profiling] Error while stopping JS self profiler for chunk:', e); + } + } + + /** + * Send a profile chunk as a standalone envelope. + */ + private _sendProfileChunk(chunk: ProfileChunk): void { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const client = this._client!; + const sdkInfo = getSdkMetadataForEnvelopeHeader(client.getSdkMetadata?.()); + + const envelope: ProfileChunkEnvelope = createEnvelope( + { + event_id: uuid4(), + sent_at: new Date().toISOString(), + ...(sdkInfo && { sdk: sdkInfo }), + }, + [[{ type: 'profile_chunk' }, chunk]], + ) as ProfileChunkEnvelope; + + client.sendEnvelope(envelope).then(null, reason => { + DEBUG_BUILD && debug.error('Error while sending profile chunk envelope:', reason); + }); + } + + /** + * Convert from JSSelfProfile format to ContinuousThreadCpuProfile format. + */ + private _toContinuousProfile(input: { + frames: { name: string; resourceId?: number; line?: number; column?: number }[]; + stacks: { frameId: number; parentId?: number }[]; + samples: { timestamp: number; stackId?: number }[]; + resources: string[]; + }): ContinuousThreadCpuProfile { + const frames: ContinuousThreadCpuProfile['frames'] = []; + for (let i = 0; i < input.frames.length; i++) { + const f = input.frames[i]; + if (!f) { + continue; + } + frames[i] = { + function: f.name, + abs_path: typeof f.resourceId === 'number' ? input.resources[f.resourceId] : undefined, + lineno: f.line, + colno: f.column, + }; + } + + const stacks: ContinuousThreadCpuProfile['stacks'] = []; + for (let i = 0; i < input.stacks.length; i++) { + const s = input.stacks[i]; + if (!s) { + continue; + } + const list: number[] = []; + let cur: { frameId: number; parentId?: number } | undefined = s; + while (cur) { + list.push(cur.frameId); + cur = cur.parentId === undefined ? undefined : input.stacks[cur.parentId]; + } + stacks[i] = list; + } + + const samples: ContinuousThreadCpuProfile['samples'] = []; + for (let i = 0; i < input.samples.length; i++) { + const s = input.samples[i]; + if (!s) { + continue; + } + samples[i] = { + stack_id: s.stackId ?? 0, + thread_id: '0', + timestamp: performance.timeOrigin + s.timestamp, + }; + } + + return { + frames, + stacks, + samples, + thread_metadata: { '0': { name: 'main' } }, + }; + } + + /** + * Create a profile chunk envelope item from a ContinuousThreadCpuProfile. + */ + private _makeProfileChunk(profile: ContinuousThreadCpuProfile): ProfileChunk { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const client = this._client!; + const options = client.getOptions(); + const sdk = client.getSdkMetadata?.()?.sdk; + + return { + chunk_id: uuid4(), + client_sdk: { + name: sdk?.name ?? 'sentry.javascript.browser', + version: sdk?.version ?? '0.0.0', + }, + profiler_id: this._profilerId || uuid4(), + platform: 'javascript', + version: '2', + release: options.release ?? '', + environment: options.environment ?? 'production', + debug_meta: { images: applyDebugImages([]) }, + profile, + }; + } +} diff --git a/packages/browser/src/profiling/startProfileForSpan.ts b/packages/browser/src/profiling/startProfileForSpan.ts index b60a207abbce..6eaaa016d822 100644 --- a/packages/browser/src/profiling/startProfileForSpan.ts +++ b/packages/browser/src/profiling/startProfileForSpan.ts @@ -41,7 +41,7 @@ export function startProfileForSpan(span: Span): void { // event of an error or user mistake (calling span.finish multiple times), it is important that the behavior of onProfileHandler // is idempotent as we do not want any timings or profiles to be overridden by the last call to onProfileHandler. // After the original finish method is called, the event will be reported through the integration and delegated to transport. - const processedProfile: JSSelfProfile | null = null; + let processedProfile: JSSelfProfile | null = null; getCurrentScope().setContext('profile', { profile_id: profileId, @@ -90,6 +90,7 @@ export function startProfileForSpan(span: Span): void { return; } + processedProfile = profile; addProfileToGlobalCache(profileId, profile); }) .catch(error => { diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 66b202c8517f..e5594ea3dfa5 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -11,6 +11,7 @@ import { timestampInSeconds, uuid4, } from '@sentry/core'; +import type { BrowserOptions } from '../client'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; import type { JSSelfProfile, JSSelfProfiler, JSSelfProfilerConstructor, JSSelfProfileStack } from './jsSelfProfiling'; @@ -459,7 +460,7 @@ export function startJSSelfProfile(): JSSelfProfiler | undefined { /** * Determine if a profile should be profiled. */ -export function shouldProfileSpan(span: Span): boolean { +export function shouldProfileSpanLegacy(span: Span): boolean { // If constructor failed once, it will always fail, so we can early return. if (PROFILING_CONSTRUCTOR_FAILED) { if (DEBUG_BUILD) { @@ -469,9 +470,7 @@ export function shouldProfileSpan(span: Span): boolean { } if (!span.isRecording()) { - if (DEBUG_BUILD) { - debug.log('[Profiling] Discarding profile because transaction was not sampled.'); - } + DEBUG_BUILD && debug.log('[Profiling] Discarding profile because root span was not sampled.'); return false; } @@ -518,6 +517,51 @@ export function shouldProfileSpan(span: Span): boolean { return true; } +/** + * Determine if a profile should be created for the current session (lifecycle profiling mode). + */ +export function shouldProfileSession(options?: BrowserOptions): boolean { + // If constructor failed once, it will always fail, so we can early return. + if (PROFILING_CONSTRUCTOR_FAILED) { + if (DEBUG_BUILD) { + debug.log('[Profiling] Profiling has been disabled for the duration of the current user session.'); + } + return false; + } + + if (!options || options.profileLifecycle !== 'trace') { + return false; + } + + // Session sampling: profileSessionSampleRate gates whether profiling is enabled for this session + const profileSessionSampleRate: number | boolean | undefined = ( + options as unknown as { + profileSessionSampleRate?: number | boolean; + } + ).profileSessionSampleRate; + + if (!isValidSampleRate(profileSessionSampleRate)) { + DEBUG_BUILD && debug.warn('[Profiling] Discarding profile because of invalid profileSessionSampleRate.'); + return false; + } + + if (!profileSessionSampleRate) { + DEBUG_BUILD && + debug.log('[Profiling] Discarding profile because profileSessionSampleRate is not defined or set to 0'); + return false; + } + + return profileSessionSampleRate === true ? true : Math.random() <= profileSessionSampleRate; +} + +/** + * Checks if legacy profiling is configured. + */ +export function hasLegacyProfiling(options: BrowserOptions = {} as unknown as BrowserOptions): boolean { + // eslint-disable-next-line deprecation/deprecation + return typeof (options as unknown as { profilesSampleRate?: number | boolean }).profilesSampleRate !== 'undefined'; +} + /** * Creates a profiling envelope item, if the profile does not pass validation, returns null. * @param event @@ -564,7 +608,9 @@ export function addProfileToGlobalCache(profile_id: string, profile: JSSelfProfi PROFILE_MAP.set(profile_id, profile); if (PROFILE_MAP.size > 30) { - const last: string = PROFILE_MAP.keys().next().value; - PROFILE_MAP.delete(last); + const last = PROFILE_MAP.keys().next().value; + if (last !== undefined) { + PROFILE_MAP.delete(last); + } } } diff --git a/packages/browser/test/profiling/integration.test.ts b/packages/browser/test/profiling/integration.test.ts index baf0b5f64d14..7d02a9ea33c6 100644 --- a/packages/browser/test/profiling/integration.test.ts +++ b/packages/browser/test/profiling/integration.test.ts @@ -4,6 +4,7 @@ import type { BrowserClient } from '@sentry/browser'; import * as Sentry from '@sentry/browser'; +import { debug } from '@sentry/core'; import { describe, expect, it, vi } from 'vitest'; import type { JSSelfProfile } from '../../src/profiling/jsSelfProfiling'; @@ -68,4 +69,46 @@ describe('BrowserProfilingIntegration', () => { expect(profile_timestamp_ms).toBeGreaterThan(transaction_timestamp_ms); expect(profile.profile.frames[0]).toMatchObject({ function: 'pageload_fn', lineno: 1, colno: 1 }); }); + + it("warns when profileLifecycle is 'trace' but tracing is disabled", async () => { + debug.enable(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // @ts-expect-error mock constructor + window.Profiler = class { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return Promise.resolve({ frames: [], stacks: [], samples: [], resources: [] }); + } + }; + + Sentry.init({ + dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', + // no tracesSampleRate and no tracesSampler → tracing disabled + profileLifecycle: 'trace', + profileSessionSampleRate: 1, + integrations: [Sentry.browserProfilingIntegration()], + }); + + expect( + warnSpy.mock.calls.some(call => + String(call?.[1] ?? call?.[0]).includes("`profileLifecycle` is 'trace' but tracing is disabled"), + ), + ).toBe(true); + + warnSpy.mockRestore(); + }); + + it("auto-sets profileLifecycle to 'trace' when not specified", async () => { + Sentry.init({ + dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', + integrations: [Sentry.browserProfilingIntegration()], + }); + + const client = Sentry.getClient(); + const lifecycle = (client?.getOptions() as any)?.profileLifecycle; + expect(lifecycle).toBe('trace'); + }); }); diff --git a/packages/browser/test/profiling/traceLifecycle.test.ts b/packages/browser/test/profiling/traceLifecycle.test.ts new file mode 100644 index 000000000000..662b0065a590 --- /dev/null +++ b/packages/browser/test/profiling/traceLifecycle.test.ts @@ -0,0 +1,185 @@ +/** + * @vitest-environment jsdom + */ + +import * as Sentry from '@sentry/browser'; +import type { Span } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +describe('Browser Profiling v2 trace lifecycle', () => { + afterEach(async () => { + const client = Sentry.getClient(); + await client?.close(); + // reset profiler constructor + (window as any).Profiler = undefined; + vi.restoreAllMocks(); + }); + + function mockProfiler() { + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + const mockConstructor = vi.fn().mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => { + return new MockProfilerImpl(opts); + }); + + (window as any).Profiler = mockConstructor; + return { stop, mockConstructor }; + } + + it('does not start profiler when tracing is disabled (logs a warning)', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + Sentry.init({ + // tracing disabled + dsn: 'https://public@o.ingest.sentry.io/1', + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + // no tracesSampleRate/tracesSampler + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + // warning is logged by our debug logger only when DEBUG_BUILD, so just assert no throw and no profiler + const client = Sentry.getClient(); + expect(client).toBeDefined(); + expect(stop).toHaveBeenCalledTimes(0); + expect(mockConstructor).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('keeps running across overlapping sampled root spans and stops after the last ends', async () => { + const { stop } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let firstSpan: Span | undefined; + + // Start first root span manually + Sentry.startSpanManual({ name: 'manual root 1', parentSpan: null, forceTransaction: true }, span => { + firstSpan = span; + }); + + expect(stop).toBeCalledTimes(0); + + // Start second overlapping root span + Sentry.startSpan({ name: 'manual root 2', parentSpan: null, forceTransaction: true }, span => { + expect(span).toBeDefined(); + }); + + expect(stop).toBeCalledTimes(0); + + expect(firstSpan).toBeDefined(); + (firstSpan as Span).end(); + // Allow profiler to tick + await new Promise(r => setTimeout(r, 25)); + // End last overlapping root span now + const root = Sentry.getActiveSpan(); + root?.end(); + + const client = Sentry.getClient(); + // Allow profiler to finalize chunk + await new Promise(r => setTimeout(r, 25)); + await client?.flush(1000); + expect(stop).toBeCalledTimes(1); + + const envelope = send.mock.calls[0]?.[0] as any; + + const items = envelope?.[1] || []; + expect(items.some((it: any) => it?.[0]?.type === 'profile_chunk')).toBe(true); + }); + + it('starts on first sampled root span and stops after a sampled root ends', async () => { + const { mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + // End the initial pageload root first so our manual root is the only active, sampled root + const initialRoot = Sentry.getActiveSpan(); + initialRoot?.end(); + await new Promise(r => setTimeout(r, 0)); + + // Start a sampled root span explicitly so the profiler sees spanStart + let createdSpan: any; + Sentry.startSpan({ name: 'root-1', parentSpan: null, forceTransaction: true }, span => { + createdSpan = span; + + expect(span).toBeDefined(); + }); + + Sentry.startSpanManual({ name: 'root-2', parentSpan: null, forceTransaction: true }, span => {}); + + // Allow profiler to tick + await new Promise(r => setTimeout(r, 25)); + createdSpan.end(); + // Allow profiler to finalize chunk + await new Promise(r => setTimeout(r, 25)); + + const client = Sentry.getClient(); + await client?.flush(1000); + // Assert profiler started at least once; stop timing is not deterministic here + expect(mockConstructor).toHaveBeenCalled(); + const envelope = send.mock.calls[0]?.[0] as any; + const items = envelope?.[1] || []; + expect(items.some((it: any) => it?.[0]?.type === 'profile_chunk')).toBe(true); + }); + + it('does not start when profileSessionSampleRate is 0 (session not sampled)', async () => { + const { mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 0, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpan({ name: 'root', parentSpan: null, forceTransaction: true }, span => {}); + + const root = Sentry.getActiveSpan(); + expect(root).toBeDefined(); + root?.end(); + + const client = Sentry.getClient(); + await client?.flush(1000); + expect(client).toBeDefined(); + expect(mockConstructor).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + }); +}); From 2e8dc1df8281ad7300e0f7e28f29e55c01264167 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Fri, 12 Sep 2025 14:32:10 +0200 Subject: [PATCH 06/31] fix tests --- .../suites/profiling/legacyMode/test.ts | 5 - .../subject.js | 1 - .../test.ts | 306 ++++++++++-------- .../test.ts | 27 +- .../test/profiling/traceLifecycle.test.ts | 119 ------- 5 files changed, 184 insertions(+), 274 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts index fee5f216d410..35f4e17bec0a 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts @@ -15,11 +15,6 @@ sentryTest( sentryTest.skip(); } - if (shouldSkipTracingTest()) { - // Profiling only works when tracing is enabled - sentryTest.skip(); - } - const url = await getLocalTestUrl({ testDir: __dirname }); const req = await waitForTransactionRequestOnUrl(page, url); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js index 01db8102412a..0095eb5743d9 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js @@ -44,6 +44,5 @@ await Sentry.startSpanManual({ name: 'root-largeSum-2', parentSpan: null, forceT span.end(); }); -// Ensure envelope flush const client = Sentry.getClient(); await client?.flush(5000); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts index 4cc2fd4990a3..955f37cc4821 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts @@ -8,142 +8,174 @@ import { shouldSkipTracingTest, } from '../../../utils/helpers'; -sentryTest('does not send profile envelope when document-policy is not set', async ({ page, getLocalTestUrl }) => { - if (shouldSkipTracingTest()) { - // 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' } }); - await page.goto(url); - - // 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( - page, - 2, - { 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 envelopeItemPayload = profileChunkEnvelopeItem[1]; - - expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); - - expect(envelopeItemPayload.profile).toBeDefined(); - expect(envelopeItemPayload.version).toBe('2'); - expect(envelopeItemPayload.platform).toBe('javascript'); - - const profile = envelopeItemPayload.profile; - - expect(profile.samples).toBeDefined(); - expect(profile.stacks).toBeDefined(); - expect(profile.frames).toBeDefined(); - expect(profile.thread_metadata).toBeDefined(); - - // Samples - expect(profile.samples.length).toBeGreaterThanOrEqual(2); - let previousTimestamp = Number.NEGATIVE_INFINITY; - for (const sample of profile.samples) { - expect(typeof sample.stack_id).toBe('number'); - expect(sample.stack_id).toBeGreaterThanOrEqual(0); - expect(sample.stack_id).toBeLessThan(profile.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(profile.stacks.length).toBeGreaterThan(0); - for (const stack of profile.stacks) { - expect(Array.isArray(stack)).toBe(true); - for (const frameIndex of stack) { - expect(typeof frameIndex).toBe('number'); - expect(frameIndex).toBeGreaterThanOrEqual(0); - expect(frameIndex).toBeLessThan(profile.frames.length); +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(); } - } - - // Frames - 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'); - } - - const functionNames = profile.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', - - // both functions are captured - 'fibonacci', - 'largeSum', - ]), + + 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' } }); + await page.goto(url); + + // 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( + page, + 2, + { envelopeType: 'profile_chunk', timeout: 5000 }, + properFullEnvelopeRequestParser, ); - } - - expect(profile.thread_metadata).toHaveProperty('0'); - expect(profile.thread_metadata['0']).toHaveProperty('name'); - expect(profile.thread_metadata['0'].name).toBe('main'); - - // Test that profile duration makes sense (should be > 20ms based on test setup) - const startTimeMs = (profile.samples[0] as any).timestamp as number; - const endTimeMs = (profile.samples[profile.samples.length - 1] as any).timestamp as number; - const durationMs = endTimeMs - startTimeMs; - - // Should be at least 20ms based on our setTimeout(21) in the test - expect(durationMs).toBeGreaterThan(20); - - // Basic sanity on the second chunk: has correct envelope type and structure - const secondChunkItem = profileChunkEnvelopes[1][1][0]; - const secondHeader = secondChunkItem[0]; - const secondPayload = secondChunkItem[1]; - expect(secondHeader).toHaveProperty('type', 'profile_chunk'); - expect(secondPayload.profile).toBeDefined(); - expect(secondPayload.version).toBe('2'); - expect(secondPayload.platform).toBe('javascript'); -}); + + 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'); + + 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(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'); + } + + 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 startTimeMs = (profile1.samples[0] as any).timestamp as number; + const endTimeMs = (profile1.samples[profile1.samples.length - 1] as any).timestamp as number; + const durationMs = endTimeMs - startTimeMs; + + // Should be at least 20ms based on our setTimeout(21) in the test + expect(durationMs).toBeGreaterThan(20); + + // === 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'); + expect(envelopeItemPayload2?.profile).toBeDefined(); + + 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', + ]), + ); + } + }, +); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts index c5ee7ccb3cf1..c20ed2cfa609 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts @@ -9,22 +9,25 @@ import { waitForTransactionRequestOnUrl, } from '../../../utils/helpers'; -sentryTest('does not send profile envelope when document-policy is not set', async ({ page, getLocalTestUrl }) => { - if (shouldSkipTracingTest()) { - // Profiling only works when tracing is enabled - sentryTest.skip(); - } +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 }); + const url = await getLocalTestUrl({ testDir: __dirname }); - const req = await waitForTransactionRequestOnUrl(page, url); - const transactionEvent = properEnvelopeRequestParser(req, 0); - const profileEvent = properEnvelopeRequestParser(req, 1); + const req = await waitForTransactionRequestOnUrl(page, url); + const transactionEvent = properEnvelopeRequestParser(req, 0); + const profileEvent = properEnvelopeRequestParser(req, 1); - expect(transactionEvent).toBeDefined(); + expect(transactionEvent).toBeDefined(); - expect(profileEvent).toBeUndefined(); -}); + expect(profileEvent).toBeUndefined(); + }, +); sentryTest( 'sends profile envelope in trace mode (single chunk for overlapping spans)', diff --git a/packages/browser/test/profiling/traceLifecycle.test.ts b/packages/browser/test/profiling/traceLifecycle.test.ts index 662b0065a590..87d3868861f2 100644 --- a/packages/browser/test/profiling/traceLifecycle.test.ts +++ b/packages/browser/test/profiling/traceLifecycle.test.ts @@ -3,7 +3,6 @@ */ import * as Sentry from '@sentry/browser'; -import type { Span } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; describe('Browser Profiling v2 trace lifecycle', () => { @@ -64,122 +63,4 @@ describe('Browser Profiling v2 trace lifecycle', () => { expect(send).not.toHaveBeenCalled(); warn.mockRestore(); }); - - it('keeps running across overlapping sampled root spans and stops after the last ends', async () => { - const { stop } = mockProfiler(); - const send = vi.fn().mockResolvedValue(undefined); - - Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), - }); - - let firstSpan: Span | undefined; - - // Start first root span manually - Sentry.startSpanManual({ name: 'manual root 1', parentSpan: null, forceTransaction: true }, span => { - firstSpan = span; - }); - - expect(stop).toBeCalledTimes(0); - - // Start second overlapping root span - Sentry.startSpan({ name: 'manual root 2', parentSpan: null, forceTransaction: true }, span => { - expect(span).toBeDefined(); - }); - - expect(stop).toBeCalledTimes(0); - - expect(firstSpan).toBeDefined(); - (firstSpan as Span).end(); - // Allow profiler to tick - await new Promise(r => setTimeout(r, 25)); - // End last overlapping root span now - const root = Sentry.getActiveSpan(); - root?.end(); - - const client = Sentry.getClient(); - // Allow profiler to finalize chunk - await new Promise(r => setTimeout(r, 25)); - await client?.flush(1000); - expect(stop).toBeCalledTimes(1); - - const envelope = send.mock.calls[0]?.[0] as any; - - const items = envelope?.[1] || []; - expect(items.some((it: any) => it?.[0]?.type === 'profile_chunk')).toBe(true); - }); - - it('starts on first sampled root span and stops after a sampled root ends', async () => { - const { mockConstructor } = mockProfiler(); - const send = vi.fn().mockResolvedValue(undefined); - - Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), - }); - - // End the initial pageload root first so our manual root is the only active, sampled root - const initialRoot = Sentry.getActiveSpan(); - initialRoot?.end(); - await new Promise(r => setTimeout(r, 0)); - - // Start a sampled root span explicitly so the profiler sees spanStart - let createdSpan: any; - Sentry.startSpan({ name: 'root-1', parentSpan: null, forceTransaction: true }, span => { - createdSpan = span; - - expect(span).toBeDefined(); - }); - - Sentry.startSpanManual({ name: 'root-2', parentSpan: null, forceTransaction: true }, span => {}); - - // Allow profiler to tick - await new Promise(r => setTimeout(r, 25)); - createdSpan.end(); - // Allow profiler to finalize chunk - await new Promise(r => setTimeout(r, 25)); - - const client = Sentry.getClient(); - await client?.flush(1000); - // Assert profiler started at least once; stop timing is not deterministic here - expect(mockConstructor).toHaveBeenCalled(); - const envelope = send.mock.calls[0]?.[0] as any; - const items = envelope?.[1] || []; - expect(items.some((it: any) => it?.[0]?.type === 'profile_chunk')).toBe(true); - }); - - it('does not start when profileSessionSampleRate is 0 (session not sampled)', async () => { - const { mockConstructor } = mockProfiler(); - const send = vi.fn().mockResolvedValue(undefined); - - Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 0, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), - }); - - Sentry.startSpan({ name: 'root', parentSpan: null, forceTransaction: true }, span => {}); - - const root = Sentry.getActiveSpan(); - expect(root).toBeDefined(); - root?.end(); - - const client = Sentry.getClient(); - await client?.flush(1000); - expect(client).toBeDefined(); - expect(mockConstructor).not.toHaveBeenCalled(); - expect(send).not.toHaveBeenCalled(); - }); }); From 3c6ed94cd98a489bc1f1523876c8d65f973ab5d2 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Fri, 12 Sep 2025 14:39:47 +0200 Subject: [PATCH 07/31] add unit tests --- .../test/profiling/traceLifecycle.test.ts | 128 +++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/packages/browser/test/profiling/traceLifecycle.test.ts b/packages/browser/test/profiling/traceLifecycle.test.ts index 87d3868861f2..49c1ecbe8e8a 100644 --- a/packages/browser/test/profiling/traceLifecycle.test.ts +++ b/packages/browser/test/profiling/traceLifecycle.test.ts @@ -3,7 +3,7 @@ */ import * as Sentry from '@sentry/browser'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; describe('Browser Profiling v2 trace lifecycle', () => { afterEach(async () => { @@ -63,4 +63,130 @@ describe('Browser Profiling v2 trace lifecycle', () => { expect(send).not.toHaveBeenCalled(); warn.mockRestore(); }); + + describe('profiling lifecycle behavior', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts on first sampled root span and sends a chunk on stop', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanRef: any; + Sentry.startSpanManual({ name: 'root-1', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + // Ending the only root span should flush one chunk immediately + spanRef.end(); + + // Resolve any pending microtasks + await Promise.resolve(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(2); // one for transaction, one for profile_chunk + const transactionEnvelopeHeader = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + const profileChunkEnvelopeHeader = send.mock.calls?.[1]?.[0]?.[1]?.[0]?.[0]; + expect(profileChunkEnvelopeHeader?.type).toBe('profile_chunk'); + expect(transactionEnvelopeHeader?.type).toBe('transaction'); + }); + + it('continues while any sampled root span is active; stops after last ends', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanA: any; + Sentry.startSpanManual({ name: 'root-A', parentSpan: null, forceTransaction: true }, span => { + spanA = span; + }); + + let spanB: any; + Sentry.startSpanManual({ name: 'root-B', parentSpan: null, forceTransaction: true }, span => { + spanB = span; + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + // End first root span -> still one active sampled root span; no send yet + spanA.end(); + await Promise.resolve(); + expect(stop).toHaveBeenCalledTimes(0); + expect(send).toHaveBeenCalledTimes(1); // only transaction so far + const envelopeHeadersTxn = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + expect(envelopeHeadersTxn?.type).toBe('transaction'); + + // End last root span -> should flush one chunk + spanB.end(); + await Promise.resolve(); + expect(stop).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(3); + const envelopeHeadersTxn1 = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + const envelopeHeadersTxn2 = send.mock.calls?.[1]?.[0]?.[1]?.[0]?.[0]; + const envelopeHeadersProfile = send.mock.calls?.[2]?.[0]?.[1]?.[0]?.[0]; + + expect(envelopeHeadersTxn1?.type).toBe('transaction'); + expect(envelopeHeadersTxn2?.type).toBe('transaction'); + expect(envelopeHeadersProfile?.type).toBe('profile_chunk'); + }); + + it('sends a periodic chunk every 60s while running and restarts profiler', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanRef: any; + Sentry.startSpanManual({ name: 'root-interval', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + // Advance timers by 60s to trigger scheduled chunk collection + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + + // One chunk sent and profiler restarted (second constructor call) + expect(stop).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(1); + expect(mockConstructor).toHaveBeenCalledTimes(2); + const envelopeHeaders = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + expect(envelopeHeaders?.type).toBe('profile_chunk'); + + // Clean up + spanRef.end(); + await Promise.resolve(); + }); + }); }); From 718be4f792d2c5445846b1f5c814f68e8c492ed8 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Mon, 15 Sep 2025 13:40:04 +0200 Subject: [PATCH 08/31] change envelope typing --- .../src/profiling/session/traceLifecycleProfiler.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/browser/src/profiling/session/traceLifecycleProfiler.ts b/packages/browser/src/profiling/session/traceLifecycleProfiler.ts index ff8fea07f114..fc281ff415c2 100644 --- a/packages/browser/src/profiling/session/traceLifecycleProfiler.ts +++ b/packages/browser/src/profiling/session/traceLifecycleProfiler.ts @@ -3,6 +3,7 @@ import { type ProfileChunkEnvelope, createEnvelope, debug, + dsnToString, getRootSpan, getSdkMetadataForEnvelopeHeader, spanToJSON, @@ -237,16 +238,20 @@ export class BrowserTraceLifecycleProfiler { private _sendProfileChunk(chunk: ProfileChunk): void { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const client = this._client!; + const sdkInfo = getSdkMetadataForEnvelopeHeader(client.getSdkMetadata?.()); + const dsn = client.getDsn(); + const tunnel = client.getOptions().tunnel; - const envelope: ProfileChunkEnvelope = createEnvelope( + const envelope = createEnvelope( { event_id: uuid4(), sent_at: new Date().toISOString(), ...(sdkInfo && { sdk: sdkInfo }), + ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), }, [[{ type: 'profile_chunk' }, chunk]], - ) as ProfileChunkEnvelope; + ); client.sendEnvelope(envelope).then(null, reason => { DEBUG_BUILD && debug.error('Error while sending profile chunk envelope:', reason); From 9a0606f1f56b2c61229f40f23023349536a136d9 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Mon, 15 Sep 2025 16:41:04 +0200 Subject: [PATCH 09/31] put util functions in utils --- packages/browser/src/profiling/integration.ts | 2 +- .../traceLifecycleProfiler.ts | 91 +------------- packages/browser/src/profiling/utils.ts | 116 +++++++++++++++++- 3 files changed, 120 insertions(+), 89 deletions(-) rename packages/browser/src/profiling/{session => lifecycleMode}/traceLifecycleProfiler.ts (73%) diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 3ef3bfc50644..5ed3f0e2fab3 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -3,7 +3,7 @@ import { debug, defineIntegration, getActiveSpan, getRootSpan, hasSpansEnabled } import type { BrowserOptions } from '../client'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; -import { BrowserTraceLifecycleProfiler } from './session/traceLifecycleProfiler'; +import { BrowserTraceLifecycleProfiler } from './lifecycleMode/traceLifecycleProfiler'; import { startProfileForSpan } from './startProfileForSpan'; import type { ProfiledEvent } from './utils'; import { diff --git a/packages/browser/src/profiling/session/traceLifecycleProfiler.ts b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts similarity index 73% rename from packages/browser/src/profiling/session/traceLifecycleProfiler.ts rename to packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts index fc281ff415c2..37adb8ff4813 100644 --- a/packages/browser/src/profiling/session/traceLifecycleProfiler.ts +++ b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts @@ -11,7 +11,7 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { JSSelfProfiler } from '../jsSelfProfiling'; -import { applyDebugMetadata as applyDebugImages, startJSSelfProfile } from '../utils'; +import { createProfileChunkPayload, startJSSelfProfile } from '../utils'; const CHUNK_INTERVAL_MS = 60_000; @@ -222,8 +222,9 @@ export class BrowserTraceLifecycleProfiler { try { const profile = await profiler.stop(); - const continuous = this._toContinuousProfile(profile); - const chunk: ProfileChunk = this._makeProfileChunk(continuous); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const chunk = createProfileChunkPayload(profile, this._client!, this._profilerId); this._sendProfileChunk(chunk); DEBUG_BUILD && debug.log('[Profiling] Collected browser profile chunk.'); @@ -257,88 +258,4 @@ export class BrowserTraceLifecycleProfiler { DEBUG_BUILD && debug.error('Error while sending profile chunk envelope:', reason); }); } - - /** - * Convert from JSSelfProfile format to ContinuousThreadCpuProfile format. - */ - private _toContinuousProfile(input: { - frames: { name: string; resourceId?: number; line?: number; column?: number }[]; - stacks: { frameId: number; parentId?: number }[]; - samples: { timestamp: number; stackId?: number }[]; - resources: string[]; - }): ContinuousThreadCpuProfile { - const frames: ContinuousThreadCpuProfile['frames'] = []; - for (let i = 0; i < input.frames.length; i++) { - const f = input.frames[i]; - if (!f) { - continue; - } - frames[i] = { - function: f.name, - abs_path: typeof f.resourceId === 'number' ? input.resources[f.resourceId] : undefined, - lineno: f.line, - colno: f.column, - }; - } - - const stacks: ContinuousThreadCpuProfile['stacks'] = []; - for (let i = 0; i < input.stacks.length; i++) { - const s = input.stacks[i]; - if (!s) { - continue; - } - const list: number[] = []; - let cur: { frameId: number; parentId?: number } | undefined = s; - while (cur) { - list.push(cur.frameId); - cur = cur.parentId === undefined ? undefined : input.stacks[cur.parentId]; - } - stacks[i] = list; - } - - const samples: ContinuousThreadCpuProfile['samples'] = []; - for (let i = 0; i < input.samples.length; i++) { - const s = input.samples[i]; - if (!s) { - continue; - } - samples[i] = { - stack_id: s.stackId ?? 0, - thread_id: '0', - timestamp: performance.timeOrigin + s.timestamp, - }; - } - - return { - frames, - stacks, - samples, - thread_metadata: { '0': { name: 'main' } }, - }; - } - - /** - * Create a profile chunk envelope item from a ContinuousThreadCpuProfile. - */ - private _makeProfileChunk(profile: ContinuousThreadCpuProfile): ProfileChunk { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const client = this._client!; - const options = client.getOptions(); - const sdk = client.getSdkMetadata?.()?.sdk; - - return { - chunk_id: uuid4(), - client_sdk: { - name: sdk?.name ?? 'sentry.javascript.browser', - version: sdk?.version ?? '0.0.0', - }, - profiler_id: this._profilerId || uuid4(), - platform: 'javascript', - version: '2', - release: options.release ?? '', - environment: options.environment ?? 'production', - debug_meta: { images: applyDebugImages([]) }, - profile, - }; - } } diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index e5594ea3dfa5..24cd1f0b9838 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -1,5 +1,16 @@ /* eslint-disable max-lines */ -import type { DebugImage, Envelope, Event, EventEnvelope, Profile, Span, ThreadCpuProfile } from '@sentry/core'; +import type { + Client, + ContinuousThreadCpuProfile, + DebugImage, + Envelope, + Event, + EventEnvelope, + Profile, + ProfileChunk, + Span, + ThreadCpuProfile, +} from '@sentry/core'; import { browserPerformanceTimeOrigin, debug, @@ -196,6 +207,109 @@ export function createProfilePayload( return profile; } +/** + * Create a profile chunk envelope item + */ +export function createProfileChunkPayload( + jsSelfProfile: JSSelfProfile, + client: Client, + profilerId?: string, +): ProfileChunk { + if (jsSelfProfile === undefined || jsSelfProfile === null) { + throw new TypeError( + `Cannot construct profiling event envelope without a valid profile. Got ${jsSelfProfile} instead.`, + ); + } + + const continuousProfile = convertToContinuousProfile(jsSelfProfile); + + const options = client.getOptions(); + const sdk = client.getSdkMetadata?.()?.sdk; + + return { + chunk_id: uuid4(), + client_sdk: { + name: sdk?.name ?? 'sentry.javascript.browser', + version: sdk?.version ?? '0.0.0', + }, + profiler_id: profilerId || uuid4(), + platform: 'javascript', + version: '2', + release: options.release ?? '', + environment: options.environment ?? 'production', + debug_meta: { images: applyDebugMetadata([]) }, + profile: continuousProfile, + }; +} + +/** + * Convert from JSSelfProfile format to ContinuousThreadCpuProfile format. + */ +function convertToContinuousProfile(input: { + frames: { name: string; resourceId?: number; line?: number; column?: number }[]; + stacks: { frameId: number; parentId?: number }[]; + samples: { timestamp: number; stackId?: number }[]; + resources: string[]; +}): ContinuousThreadCpuProfile { + // Frames map 1:1 by index; fill only when present to avoid sparse writes + const frames: ContinuousThreadCpuProfile['frames'] = []; + for (let i = 0; i < input.frames.length; i++) { + const frame = input.frames[i]; + if (!frame) { + continue; + } + frames[i] = { + function: frame.name, + abs_path: typeof frame.resourceId === 'number' ? input.resources[frame.resourceId] : undefined, + lineno: frame.line, + colno: frame.column, + }; + } + + // Build stacks by following parent links, top->down order (root last) + const stacks: ContinuousThreadCpuProfile['stacks'] = []; + for (let i = 0; i < input.stacks.length; i++) { + const stackHead = input.stacks[i]; + if (!stackHead) { + continue; + } + const list: number[] = []; + let current: { frameId: number; parentId?: number } | undefined = stackHead; + while (current) { + list.push(current.frameId); + current = current.parentId === undefined ? undefined : input.stacks[current.parentId]; + } + stacks[i] = list; + } + + // Align timestamps to SDK time origin to match span/event timelines + const perfOrigin = browserPerformanceTimeOrigin(); + const origin = typeof performance.timeOrigin === 'number' ? performance.timeOrigin : perfOrigin || 0; + const adjustForOriginChange = origin - (perfOrigin || origin); + + const samples: ContinuousThreadCpuProfile['samples'] = []; + for (let i = 0; i < input.samples.length; i++) { + const sample = input.samples[i]; + if (!sample) { + continue; + } + // Convert ms to seconds epoch-based timestamp + const timestampSeconds = (origin + (sample.timestamp - adjustForOriginChange)) / 1000; + samples[i] = { + stack_id: sample.stackId ?? 0, + thread_id: THREAD_ID_STRING, + timestamp: timestampSeconds, + }; + } + + return { + frames, + stacks, + samples, + thread_metadata: { [THREAD_ID_STRING]: { name: THREAD_NAME } }, + }; +} + /** * */ From ec0fd2719c0670f4a22c599aca423d41879d29db Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 16 Sep 2025 16:53:05 +0200 Subject: [PATCH 10/31] ci(test-matrix): Add logs for `getTestMatrix` --- dev-packages/e2e-tests/lib/getTestMatrix.ts | 48 ++++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/lib/getTestMatrix.ts b/dev-packages/e2e-tests/lib/getTestMatrix.ts index 1261e7d5b3ac..6f375d194c02 100644 --- a/dev-packages/e2e-tests/lib/getTestMatrix.ts +++ b/dev-packages/e2e-tests/lib/getTestMatrix.ts @@ -48,10 +48,24 @@ function run(): void { const { base, head = 'HEAD', optional } = values; + // For GitHub Action debugging + // eslint-disable-next-line no-console + console.log(`Parsed command line arguments: base=${base}, head=${head}, optional=${optional}`); + const testApplications = globSync('*/package.json', { cwd: `${__dirname}/../test-applications`, }).map(filePath => dirname(filePath)); + // For GitHub Action debugging + // eslint-disable-next-line no-console + console.log( + `Discovered ${testApplications.length} test applications${ + testApplications.length > 0 + ? ` (sample: ${JSON.stringify(testApplications.slice(0, 10))}${testApplications.length > 10 ? ' …' : ''})` + : '' + }`, + ); + // If `--base=xxx` is defined, we only want to get test applications changed since that base // Else, we take all test applications (e.g. on push) const includedTestApplications = base @@ -137,11 +151,22 @@ function getAffectedTestApplications( additionalArgs.push(`--head=${head}`); } - const affectedProjects = execSync(`yarn --silent nx show projects --affected ${additionalArgs.join(' ')}`) - .toString() - .split('\n') - .map(line => line.trim()) - .filter(Boolean); + let affectedProjects: string[] = []; + try { + affectedProjects = execSync(`yarn --silent nx show projects --affected ${additionalArgs.join(' ')}`) + .toString() + .split('\n') + .map(line => line.trim()) + .filter(Boolean); + } catch (error) { + // eslint-disable-next-line no-console + console.warn('Failed to compute affected projects via Nx. Running all tests instead.', error); + return testApplications; + } + + // For GitHub Action debugging + // eslint-disable-next-line no-console + console.log('Nx affected projects:', JSON.stringify(affectedProjects)); // If something in e2e tests themselves are changed, check if only test applications were changed if (affectedProjects.includes('@sentry-internal/e2e-tests')) { @@ -150,12 +175,19 @@ function getAffectedTestApplications( // Shared code was changed, run all tests if (changedTestApps === false) { + // eslint-disable-next-line no-console + console.log('Shared e2e code changed. Running all test applications.'); return testApplications; } // Only test applications that were changed, run selectively if (changedTestApps.size > 0) { - return testApplications.filter(testApp => changedTestApps.has(testApp)); + const selected = testApplications.filter(testApp => changedTestApps.has(testApp)); + // eslint-disable-next-line no-console + console.log( + `Only changed test applications will run (${selected.length}): ${JSON.stringify(Array.from(changedTestApps))}`, + ); + return selected; } } catch (error) { // eslint-disable-next-line no-console @@ -182,6 +214,10 @@ function getChangedTestApps(base: string, head?: string): false | Set { .map(line => line.trim()) .filter(Boolean); + // For GitHub Action debugging + // eslint-disable-next-line no-console + console.log(`Changed files since ${base}${head ? `..${head}` : ''}:`, JSON.stringify(changedFiles)); + const changedTestApps: Set = new Set(); const testAppsPrefix = 'dev-packages/e2e-tests/test-applications/'; From ae5bae495b51e279f7fc70273cd6e0daf4f64bad Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 16 Sep 2025 16:56:38 +0200 Subject: [PATCH 11/31] Revert "ci(test-matrix): Add logs for `getTestMatrix`" This reverts commit ec0fd2719c0670f4a22c599aca423d41879d29db. --- dev-packages/e2e-tests/lib/getTestMatrix.ts | 48 +++------------------ 1 file changed, 6 insertions(+), 42 deletions(-) diff --git a/dev-packages/e2e-tests/lib/getTestMatrix.ts b/dev-packages/e2e-tests/lib/getTestMatrix.ts index 6f375d194c02..1261e7d5b3ac 100644 --- a/dev-packages/e2e-tests/lib/getTestMatrix.ts +++ b/dev-packages/e2e-tests/lib/getTestMatrix.ts @@ -48,24 +48,10 @@ function run(): void { const { base, head = 'HEAD', optional } = values; - // For GitHub Action debugging - // eslint-disable-next-line no-console - console.log(`Parsed command line arguments: base=${base}, head=${head}, optional=${optional}`); - const testApplications = globSync('*/package.json', { cwd: `${__dirname}/../test-applications`, }).map(filePath => dirname(filePath)); - // For GitHub Action debugging - // eslint-disable-next-line no-console - console.log( - `Discovered ${testApplications.length} test applications${ - testApplications.length > 0 - ? ` (sample: ${JSON.stringify(testApplications.slice(0, 10))}${testApplications.length > 10 ? ' …' : ''})` - : '' - }`, - ); - // If `--base=xxx` is defined, we only want to get test applications changed since that base // Else, we take all test applications (e.g. on push) const includedTestApplications = base @@ -151,22 +137,11 @@ function getAffectedTestApplications( additionalArgs.push(`--head=${head}`); } - let affectedProjects: string[] = []; - try { - affectedProjects = execSync(`yarn --silent nx show projects --affected ${additionalArgs.join(' ')}`) - .toString() - .split('\n') - .map(line => line.trim()) - .filter(Boolean); - } catch (error) { - // eslint-disable-next-line no-console - console.warn('Failed to compute affected projects via Nx. Running all tests instead.', error); - return testApplications; - } - - // For GitHub Action debugging - // eslint-disable-next-line no-console - console.log('Nx affected projects:', JSON.stringify(affectedProjects)); + const affectedProjects = execSync(`yarn --silent nx show projects --affected ${additionalArgs.join(' ')}`) + .toString() + .split('\n') + .map(line => line.trim()) + .filter(Boolean); // If something in e2e tests themselves are changed, check if only test applications were changed if (affectedProjects.includes('@sentry-internal/e2e-tests')) { @@ -175,19 +150,12 @@ function getAffectedTestApplications( // Shared code was changed, run all tests if (changedTestApps === false) { - // eslint-disable-next-line no-console - console.log('Shared e2e code changed. Running all test applications.'); return testApplications; } // Only test applications that were changed, run selectively if (changedTestApps.size > 0) { - const selected = testApplications.filter(testApp => changedTestApps.has(testApp)); - // eslint-disable-next-line no-console - console.log( - `Only changed test applications will run (${selected.length}): ${JSON.stringify(Array.from(changedTestApps))}`, - ); - return selected; + return testApplications.filter(testApp => changedTestApps.has(testApp)); } } catch (error) { // eslint-disable-next-line no-console @@ -214,10 +182,6 @@ function getChangedTestApps(base: string, head?: string): false | Set { .map(line => line.trim()) .filter(Boolean); - // For GitHub Action debugging - // eslint-disable-next-line no-console - console.log(`Changed files since ${base}${head ? `..${head}` : ''}:`, JSON.stringify(changedFiles)); - const changedTestApps: Set = new Set(); const testAppsPrefix = 'dev-packages/e2e-tests/test-applications/'; From dc2e57f0ac88cba38413eb67fbfa4e271f5dd5e0 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 16 Sep 2025 16:58:17 +0200 Subject: [PATCH 12/31] some refactoring --- packages/browser/src/profiling/integration.ts | 22 +++---- .../lifecycleMode/traceLifecycleProfiler.ts | 61 ++++++++++++------- packages/browser/src/profiling/utils.ts | 5 +- 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 5ed3f0e2fab3..580b319462fe 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -40,19 +40,19 @@ const _browserProfilingIntegration = (() => { // UI PROFILING (Profiling V2) if (!hasLegacyProfiling(options)) { - const lifecycleMode = options.profileLifecycle; - - if (lifecycleMode === 'trace' && !hasSpansEnabled(options)) { - DEBUG_BUILD && - debug.warn( - "[Profiling] `profileLifecycle` is 'trace' but tracing is disabled. Set a `tracesSampleRate` or `tracesSampler` to enable span tracing.", - ); + const sessionSampled = shouldProfileSession(options); + if (!sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled. Skipping lifecycle profiler initialization.'); } - if (lifecycleMode === 'trace' && hasSpansEnabled(options)) { - const sessionSampled = shouldProfileSession(options); - if (!sessionSampled) { - DEBUG_BUILD && debug.log('[Profiling] Session not sampled. Skipping lifecycle profiler initialization.'); + const lifecycleMode = options.profileLifecycle; + + if (lifecycleMode === 'trace') { + if (!hasSpansEnabled(options)) { + DEBUG_BUILD && + debug.warn( + "[Profiling] `profileLifecycle` is 'trace' but tracing is disabled. Set a `tracesSampleRate` or `tracesSampler` to enable span tracing.", + ); return; } diff --git a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts index 37adb8ff4813..cce5c950f572 100644 --- a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts +++ b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts @@ -1,9 +1,10 @@ -import type { Client, ContinuousThreadCpuProfile, ProfileChunk, Span } from '@sentry/core'; +import type { Client, ContinuousThreadCpuProfile, Event, ProfileChunk, Span } from '@sentry/core'; import { type ProfileChunkEnvelope, createEnvelope, debug, dsnToString, + getGlobalScope, getRootSpan, getSdkMetadataForEnvelopeHeader, spanToJSON, @@ -30,8 +31,9 @@ export class BrowserTraceLifecycleProfiler { private _profiler: JSSelfProfiler | undefined; private _chunkTimer: ReturnType | undefined; private _activeRootSpanCount: number; - private _rootSpanIds: Set; - private _profilerId: string | undefined; + // For keeping track of active root spans + private _activeRootSpanIds: Set; + private _profileId: string | undefined; private _isRunning: boolean; private _sessionSampled: boolean; @@ -40,8 +42,8 @@ export class BrowserTraceLifecycleProfiler { this._profiler = undefined; this._chunkTimer = undefined; this._activeRootSpanCount = 0; - this._rootSpanIds = new Set(); - this._profilerId = undefined; + this._activeRootSpanIds = new Set(); + this._profileId = undefined; this._isRunning = false; this._sessionSampled = false; } @@ -69,18 +71,23 @@ export class BrowserTraceLifecycleProfiler { return; } - const spanId = spanToJSON(span)?.span_id as string | undefined; + const rootSpanJSON = spanToJSON(span); + const spanId = rootSpanJSON.span_id as string | undefined; if (!spanId) { return; } - if (this._rootSpanIds.has(spanId)) { + if (this._activeRootSpanIds.has(spanId)) { return; } - this._rootSpanIds.add(spanId); + this._activeRootSpanIds.add(spanId); const wasZero = this._activeRootSpanCount === 0; this._activeRootSpanCount++; // Increment before eventually starting the profiler - DEBUG_BUILD && debug.log('[Profiling] Root span started. Active root spans:', this._activeRootSpanCount); + DEBUG_BUILD && + debug.log( + `[Profiling] Root span ${rootSpanJSON.description} started. Active root spans:`, + this._activeRootSpanCount, + ); if (wasZero) { this.start(); } @@ -90,14 +97,19 @@ export class BrowserTraceLifecycleProfiler { if (!this._sessionSampled) { return; } - const spanId = spanToJSON(span)?.span_id as string | undefined; - if (!spanId || !this._rootSpanIds.has(spanId)) { + + const spanJSON = spanToJSON(span); + const spanId = spanJSON.span_id as string | undefined; + if (!spanId || !this._activeRootSpanIds.has(spanId)) { return; } - this._rootSpanIds.delete(spanId); + this._activeRootSpanIds.delete(spanId); this._activeRootSpanCount = Math.max(0, this._activeRootSpanCount - 1); - DEBUG_BUILD && debug.log('[Profiling] Root span ended. Active root spans:', this._activeRootSpanCount); + DEBUG_BUILD && + debug.log( + `[Profiling] Root span ${spanJSON.description} ended. Active root spans: ${this._activeRootSpanCount}`, + ); if (this._activeRootSpanCount === 0) { this._collectCurrentChunk().catch(() => { /* no catch */ @@ -118,13 +130,13 @@ export class BrowserTraceLifecycleProfiler { return; } const spanId = spanToJSON(span)?.span_id; - if (!spanId || this._rootSpanIds.has(spanId)) { + if (!spanId || this._activeRootSpanIds.has(spanId)) { return; } const wasZero = this._activeRootSpanCount === 0; - this._rootSpanIds.add(spanId); + this._activeRootSpanIds.add(spanId); this._activeRootSpanCount++; DEBUG_BUILD && debug.log( @@ -148,9 +160,15 @@ export class BrowserTraceLifecycleProfiler { this._isRunning = true; if (!this._profilerId) { this._profilerId = uuid4(); + if (!this._profileId) { + this._profileId = uuid4(); + + getGlobalScope().setContext('profile', { + profile_id: this._profileId, + }); } - DEBUG_BUILD && debug.log('[Profiling] Started profiling with profile ID:', this._profilerId); + DEBUG_BUILD && debug.log('[Profiling] Started profiling with profile ID:', this._profileId); this._startProfilerInstance(); this._scheduleNextChunk(); @@ -170,7 +188,7 @@ export class BrowserTraceLifecycleProfiler { /* no catch */ }); // Reset profiler id so a new continuous session gets a fresh id - this._profilerId = undefined; + this._profileId = undefined; } /** @@ -214,17 +232,18 @@ export class BrowserTraceLifecycleProfiler { * Stop the current profiler, convert and send a profile chunk. */ private async _collectCurrentChunk(): Promise { - const profiler = this._profiler; + const prevProfiler = this._profiler; this._profiler = undefined; - if (!profiler) { + getGlobalScope().setContext('profile', {}); + if (!prevProfiler) { return; } try { - const profile = await profiler.stop(); + const profile = await prevProfiler.stop(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const chunk = createProfileChunkPayload(profile, this._client!, this._profilerId); + const chunk = createProfileChunkPayload(profile, this._client!, this._profileId); this._sendProfileChunk(chunk); DEBUG_BUILD && debug.log('[Profiling] Collected browser profile chunk.'); diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 24cd1f0b9838..790917fdaf55 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -237,7 +237,10 @@ export function createProfileChunkPayload( version: '2', release: options.release ?? '', environment: options.environment ?? 'production', - debug_meta: { images: applyDebugMetadata([]) }, + debug_meta: { + // function name obfuscation + images: applyDebugMetadata(jsSelfProfile.resources), + }, profile: continuousProfile, }; } From 924e85a3c5ec66065a694c73ed2c9d6432574e3a Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Wed, 17 Sep 2025 17:33:18 +0200 Subject: [PATCH 13/31] web worker stuff --- packages/browser/src/profiling/utils.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 790917fdaf55..ec2f2478f851 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -18,6 +18,7 @@ import { forEachEnvelopeItem, getClient, getDebugImagesForResources, + GLOBAL_OBJ, spanToJSON, timestampInSeconds, uuid4, @@ -28,10 +29,12 @@ import { WINDOW } from '../helpers'; import type { JSSelfProfile, JSSelfProfiler, JSSelfProfilerConstructor, JSSelfProfileStack } from './jsSelfProfiling'; const MS_TO_NS = 1e6; -// Use 0 as main thread id which is identical to threadId in node:worker_threads -// where main logs 0 and workers seem to log in increments of 1 -const THREAD_ID_STRING = String(0); -const THREAD_NAME = 'main'; + +// Checking if we are in Main or Worker thread: `self` (not `window`) is the `globalThis` in Web Workers and `importScripts` are only available in Web Workers +const isMainThread = 'window' in GLOBAL_OBJ && GLOBAL_OBJ.window === GLOBAL_OBJ && typeof importScripts === 'undefined'; + +export const PROFILER_THREAD_ID_STRING = String(0); // todo: ID for Web Worker threads +export const PROFILER_THREAD_NAME = isMainThread ? 'main' : 'worker'; // We force make this optional to be on the safe side... const navigator = WINDOW.navigator as typeof WINDOW.navigator | undefined; @@ -197,7 +200,7 @@ export function createProfilePayload( name: event.transaction || '', id: event.event_id || uuid4(), trace_id: traceId, - active_thread_id: THREAD_ID_STRING, + active_thread_id: PROFILER_THREAD_ID_STRING, relative_start_ns: '0', relative_end_ns: ((transactionEndMs - transactionStartMs) * 1e6).toFixed(0), }, @@ -300,7 +303,7 @@ function convertToContinuousProfile(input: { const timestampSeconds = (origin + (sample.timestamp - adjustForOriginChange)) / 1000; samples[i] = { stack_id: sample.stackId ?? 0, - thread_id: THREAD_ID_STRING, + thread_id: PROFILER_THREAD_ID_STRING, timestamp: timestampSeconds, }; } @@ -309,7 +312,7 @@ function convertToContinuousProfile(input: { frames, stacks, samples, - thread_metadata: { [THREAD_ID_STRING]: { name: THREAD_NAME } }, + thread_metadata: { [PROFILER_THREAD_ID_STRING]: { name: PROFILER_THREAD_NAME } }, }; } @@ -344,7 +347,7 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Profi stacks: [], frames: [], thread_metadata: { - [THREAD_ID_STRING]: { name: THREAD_NAME }, + [PROFILER_THREAD_ID_STRING]: { name: PROFILER_THREAD_NAME }, }, }; @@ -376,7 +379,7 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Profi // convert ms timestamp to ns elapsed_since_start_ns: ((jsSample.timestamp + adjustForOriginChange - start) * MS_TO_NS).toFixed(0), stack_id: EMPTY_STACK_ID, - thread_id: THREAD_ID_STRING, + thread_id: PROFILER_THREAD_ID_STRING, }; return; } @@ -409,7 +412,7 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Profi // convert ms timestamp to ns elapsed_since_start_ns: ((jsSample.timestamp + adjustForOriginChange - start) * MS_TO_NS).toFixed(0), stack_id: STACK_ID, - thread_id: THREAD_ID_STRING, + thread_id: PROFILER_THREAD_ID_STRING, }; profile['stacks'][STACK_ID] = stack; From e6cf436c490f6bd6c6b71f94bd927b6a3dbced1a Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Wed, 17 Sep 2025 17:33:46 +0200 Subject: [PATCH 14/31] fix test --- packages/browser/test/profiling/integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser/test/profiling/integration.test.ts b/packages/browser/test/profiling/integration.test.ts index 7d02a9ea33c6..fca2c010e069 100644 --- a/packages/browser/test/profiling/integration.test.ts +++ b/packages/browser/test/profiling/integration.test.ts @@ -72,7 +72,7 @@ describe('BrowserProfilingIntegration', () => { it("warns when profileLifecycle is 'trace' but tracing is disabled", async () => { debug.enable(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); // @ts-expect-error mock constructor window.Profiler = class { From a30185186b91c94ef9613abc2e2a777902a2e5ac Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Wed, 17 Sep 2025 17:58:37 +0200 Subject: [PATCH 15/31] refactoring and add tests for required values --- .../test.ts | 33 +++++++-- .../test.ts | 25 +++++-- packages/browser/src/profiling/integration.ts | 4 ++ .../lifecycleMode/traceLifecycleProfiler.ts | 28 +++++--- packages/browser/src/profiling/utils.ts | 24 +++++++ .../test/profiling/traceLifecycle.test.ts | 70 +++++++++++++++++++ 6 files changed, 163 insertions(+), 21 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts index 955f37cc4821..226d948d443d 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts @@ -57,6 +57,18 @@ sentryTest( 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(); @@ -135,12 +147,12 @@ sentryTest( expect(profile1.thread_metadata['0'].name).toBe('main'); // Test that profile duration makes sense (should be > 20ms based on test setup) - const startTimeMs = (profile1.samples[0] as any).timestamp as number; - const endTimeMs = (profile1.samples[profile1.samples.length - 1] as any).timestamp as number; - const durationMs = endTimeMs - startTimeMs; + 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(durationMs).toBeGreaterThan(20); + expect(durationSec).toBeGreaterThan(0.2); // === PROFILE CHUNK 2 === @@ -155,6 +167,19 @@ sentryTest( expect(envelopeItemPayload2.platform).toBe('javascript'); expect(envelopeItemPayload2?.profile).toBeDefined(); + // 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 !== ''); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts index c20ed2cfa609..9c4b84005754 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts @@ -57,6 +57,19 @@ sentryTest( expect(envelopeItemPayload.version).toBe('2'); expect(envelopeItemPayload.platform).toBe('javascript'); + // Required profile metadata (Sample Format V2) + // https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ + expect(typeof envelopeItemPayload.profiler_id).toBe('string'); + expect(envelopeItemPayload.profiler_id).toMatch(/^[a-f0-9]{32}$/); + expect(typeof envelopeItemPayload.chunk_id).toBe('string'); + expect(envelopeItemPayload.chunk_id).toMatch(/^[a-f0-9]{32}$/); + expect(envelopeItemPayload.client_sdk).toBeDefined(); + expect(typeof envelopeItemPayload.client_sdk.name).toBe('string'); + expect(typeof envelopeItemPayload.client_sdk.version).toBe('string'); + expect(typeof envelopeItemPayload.release).toBe('string'); + expect(envelopeItemPayload.debug_meta).toBeDefined(); + expect(Array.isArray(envelopeItemPayload?.debug_meta?.images)).toBe(true); + const profile = envelopeItemPayload.profile; expect(profile.samples).toBeDefined(); @@ -73,8 +86,8 @@ sentryTest( expect(sample.stack_id).toBeLessThan(profile.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(typeof sample.timestamp).toBe('number'); + const ts = sample.timestamp; expect(Number.isFinite(ts)).toBe(true); expect(ts).toBeGreaterThan(0); // Monotonic non-decreasing timestamps @@ -136,11 +149,11 @@ sentryTest( expect(profile.thread_metadata['0'].name).toBe('main'); // Test that profile duration makes sense (should be > 20ms based on test setup) - const startTimeMs = (profile.samples[0] as any).timestamp as number; - const endTimeMs = (profile.samples[profile.samples.length - 1] as any).timestamp as number; - const durationMs = endTimeMs - startTimeMs; + const startTimeSec = (profile.samples[0] as any).timestamp as number; + const endTimeSec = (profile.samples[profile.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(durationMs).toBeGreaterThan(20); + expect(durationSec).toBeGreaterThan(0.2); }, ); diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 580b319462fe..a31b8808c59f 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -8,6 +8,7 @@ import { startProfileForSpan } from './startProfileForSpan'; import type { ProfiledEvent } from './utils'; import { addProfilesToEnvelope, + attachProfiledThreadToEvent, createProfilingEvent, findProfiledTransactionsFromEnvelope, getActiveProfilesCount, @@ -73,6 +74,9 @@ const _browserProfilingIntegration = (() => { } }, 0); } + + // Adding client hook to attach profiles to transaction events before they are sent. + client.on('beforeSendEvent', attachProfiledThreadToEvent); } else { // LEGACY PROFILING (v1) if (rootSpan && isAutomatedPageLoadSpan(rootSpan)) { diff --git a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts index cce5c950f572..d4bb014be7a6 100644 --- a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts +++ b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts @@ -1,4 +1,4 @@ -import type { Client, ContinuousThreadCpuProfile, Event, ProfileChunk, Span } from '@sentry/core'; +import type { Client, ProfileChunk, Span } from '@sentry/core'; import { type ProfileChunkEnvelope, createEnvelope, @@ -33,7 +33,7 @@ export class BrowserTraceLifecycleProfiler { private _activeRootSpanCount: number; // For keeping track of active root spans private _activeRootSpanIds: Set; - private _profileId: string | undefined; + private _profilerId: string | undefined; private _isRunning: boolean; private _sessionSampled: boolean; @@ -43,7 +43,7 @@ export class BrowserTraceLifecycleProfiler { this._chunkTimer = undefined; this._activeRootSpanCount = 0; this._activeRootSpanIds = new Set(); - this._profileId = undefined; + this._profilerId = undefined; this._isRunning = false; this._sessionSampled = false; } @@ -160,15 +160,13 @@ export class BrowserTraceLifecycleProfiler { this._isRunning = true; if (!this._profilerId) { this._profilerId = uuid4(); - if (!this._profileId) { - this._profileId = uuid4(); getGlobalScope().setContext('profile', { - profile_id: this._profileId, + profiler_id: this._profilerId, }); } - DEBUG_BUILD && debug.log('[Profiling] Started profiling with profile ID:', this._profileId); + DEBUG_BUILD && debug.log('[Profiling] Started profiling with profile ID:', this._profilerId); this._startProfilerInstance(); this._scheduleNextChunk(); @@ -187,8 +185,16 @@ export class BrowserTraceLifecycleProfiler { this._collectCurrentChunk().catch(() => { /* no catch */ }); - // Reset profiler id so a new continuous session gets a fresh id - this._profileId = undefined; + + this._resetProfilerInfo(); + } + + /** + * Resets profiling information from scope and class instance. + */ + private _resetProfilerInfo(): void { + this._profilerId = undefined; + getGlobalScope().setContext('profile', {}); } /** @@ -234,7 +240,7 @@ export class BrowserTraceLifecycleProfiler { private async _collectCurrentChunk(): Promise { const prevProfiler = this._profiler; this._profiler = undefined; - getGlobalScope().setContext('profile', {}); + if (!prevProfiler) { return; } @@ -243,7 +249,7 @@ export class BrowserTraceLifecycleProfiler { const profile = await prevProfiler.stop(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const chunk = createProfileChunkPayload(profile, this._client!, this._profileId); + const chunk = createProfileChunkPayload(profile, this._client!, this._profilerId); this._sendProfileChunk(chunk); DEBUG_BUILD && debug.log('[Profiling] Collected browser profile chunk.'); diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index ec2f2478f851..699bc9c6e54b 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -734,3 +734,27 @@ export function addProfileToGlobalCache(profile_id: string, profile: JSSelfProfi } } } + +/** + * Attaches the profiled thread information to the event's trace context. + */ +export function attachProfiledThreadToEvent(event: Event): void { + if (!event?.contexts?.profile) { + return; + } + + if (!event.contexts) { + return; + } + + // @ts-expect-error the trace fallback value is wrong, though it should never happen + // and in case it does, we dont want to override whatever was passed initially. + event.contexts.trace = { + ...(event.contexts?.trace ?? {}), + data: { + ...(event.contexts?.trace?.data ?? {}), + ['thread.id']: PROFILER_THREAD_ID_STRING, + ['thread.name']: PROFILER_THREAD_NAME, + }, + }; +} diff --git a/packages/browser/test/profiling/traceLifecycle.test.ts b/packages/browser/test/profiling/traceLifecycle.test.ts index 49c1ecbe8e8a..f8d61bc8b7ed 100644 --- a/packages/browser/test/profiling/traceLifecycle.test.ts +++ b/packages/browser/test/profiling/traceLifecycle.test.ts @@ -189,4 +189,74 @@ describe('Browser Profiling v2 trace lifecycle', () => { await Promise.resolve(); }); }); + + it('sets global profile context on transaction', async () => { + // Use real timers to avoid interference with scheduled chunk timer + vi.useRealTimers(); + + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanRef: any; + Sentry.startSpanManual({ name: 'root-for-context', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + // End span to trigger sending of the transaction + spanRef.end(); + + // Allow async tasks to resolve and flush queued envelopes + const client = Sentry.getClient(); + await client?.flush(1000); + + // Find the transaction envelope among sent envelopes + const calls = send.mock.calls; + const txnCall = calls.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction'); + expect(txnCall).toBeDefined(); + + const transaction = txnCall?.[0]?.[1]?.[0]?.[1]; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + data: expect.objectContaining({ + ['thread.id']: expect.any(String), + ['thread.name']: expect.any(String), + }), + }, + profile: { + profiler_id: expect.any(String), + }, + }, + }); + }); }); From cba6e8184b427853e28ec6b78c81ba8eaec4f266 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Thu, 18 Sep 2025 13:46:54 +0200 Subject: [PATCH 16/31] add tests for adding thread data --- .../subject.js | 4 ++ .../test.ts | 26 +++++++++++ packages/browser/src/profiling/integration.ts | 11 ++++- .../lifecycleMode/traceLifecycleProfiler.ts | 44 +++++++++++++------ packages/browser/src/profiling/utils.ts | 12 +++++ .../test/profiling/integration.test.ts | 4 +- 6 files changed, 85 insertions(+), 16 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js index 4d1db91c9b0b..bcf17a6649d9 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js @@ -35,6 +35,10 @@ Sentry.startSpanManual({ name: 'root-largeSum-1', parentSpan: null, forceTransac 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 await new Promise(resolve => setTimeout(resolve, 21)); span.end(); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts index 9c4b84005754..addece6875d1 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts @@ -157,3 +157,29 @@ sentryTest( expect(durationSec).toBeGreaterThan(0.2); }, ); + +sentryTest('attaches thread data to child spans (trace mode)', async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + const req = await waitForTransactionRequestOnUrl(page, url); + const rootSpan = properEnvelopeRequestParser(req, 0) as any; + + expect(rootSpan?.type).toBe('transaction'); + expect(rootSpan.transaction).toBe('root-fibonacci-2'); + + const profilerId = rootSpan?.contexts?.profile?.profiler_id as string | undefined; + expect(typeof profilerId).toBe('string'); + + expect(profilerId).toMatch(/^[a-f0-9]{32}$/); + + const spans = (rootSpan?.spans ?? []) as Array<{ data?: Record }>; + expect(spans.length).toBeGreaterThan(0); + for (const span of spans) { + expect(span.data).toBeDefined(); + expect(span.data?.['thread.id']).toBe('0'); + expect(span.data?.['thread.name']).toBe('main'); + } +}); diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index a31b8808c59f..e926049f148b 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -24,11 +24,13 @@ const INTEGRATION_NAME = 'BrowserProfiling'; const _browserProfilingIntegration = (() => { return { name: INTEGRATION_NAME, + // eslint-disable-next-line complexity setup(client) { const options = client.getOptions() as BrowserOptions; if (options && !hasLegacyProfiling(options) && !options.profileLifecycle) { - options.profileLifecycle = 'trace'; + // Set default lifecycle mode + options.profileLifecycle = 'manual'; } if (!options || (hasLegacyProfiling(options) && !options.profilesSampleRate)) { @@ -39,6 +41,13 @@ const _browserProfilingIntegration = (() => { const activeSpan = getActiveSpan(); const rootSpan = activeSpan && getRootSpan(activeSpan); + if (hasLegacyProfiling(options) && options.profileSessionSampleRate !== undefined) { + DEBUG_BUILD && + debug.warn( + '[Profiling] Both legacy profiling (`profilesSampleRate`) and UI profiling settings are defined. `profileSessionSampleRate` has no effect when legacy profiling is enabled.', + ); + } + // UI PROFILING (Profiling V2) if (!hasLegacyProfiling(options)) { const sessionSampled = shouldProfileSession(options); diff --git a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts index d4bb014be7a6..118b5b6b03de 100644 --- a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts +++ b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts @@ -17,7 +17,7 @@ import { createProfileChunkPayload, startJSSelfProfile } from '../utils'; const CHUNK_INTERVAL_MS = 60_000; /** - * Browser trace-lifecycle profiler (v2): + * Browser trace-lifecycle profiler (UI Profiling / Profiling 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 @@ -111,9 +111,8 @@ export class BrowserTraceLifecycleProfiler { `[Profiling] Root span ${spanJSON.description} ended. Active root spans: ${this._activeRootSpanCount}`, ); if (this._activeRootSpanCount === 0) { - this._collectCurrentChunk().catch(() => { - /* no catch */ - }); + this._collectCurrentChunk().catch(() => /* no catch */ {}); + this.stop(); } }); @@ -161,6 +160,7 @@ export class BrowserTraceLifecycleProfiler { if (!this._profilerId) { this._profilerId = uuid4(); + // Matching root spans with profiles getGlobalScope().setContext('profile', { profiler_id: this._profilerId, }); @@ -169,6 +169,14 @@ export class BrowserTraceLifecycleProfiler { DEBUG_BUILD && debug.log('[Profiling] Started profiling with profile ID:', this._profilerId); this._startProfilerInstance(); + + if (!this._profiler) { + DEBUG_BUILD && debug.log('[Profiling] Stopping trace lifecycle profiling.'); + this._isRunning = false; + this._resetProfilerInfo(); + return; + } + this._scheduleNextChunk(); } @@ -176,17 +184,22 @@ export class BrowserTraceLifecycleProfiler { * Stop profiling; final chunk will be collected and sent. */ public stop(): void { + if (!this._isRunning) { + return; + } + this._isRunning = false; if (this._chunkTimer) { clearTimeout(this._chunkTimer); this._chunkTimer = undefined; } - this._collectCurrentChunk().catch(() => { - /* no catch */ - }); - - this._resetProfilerInfo(); + // Collect whatever was currently recording + this._collectCurrentChunk() + .catch(() => /* no catch */ {}) + .finally(() => { + this._resetProfilerInfo(); + }); } /** @@ -206,7 +219,7 @@ export class BrowserTraceLifecycleProfiler { } const profiler = startJSSelfProfile(); if (!profiler) { - DEBUG_BUILD && debug.log('[Profiling] Failed to start JS self profiler in trace lifecycle.'); + DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler in trace lifecycle.'); return; } this._profiler = profiler; @@ -223,12 +236,17 @@ export class BrowserTraceLifecycleProfiler { } this._chunkTimer = setTimeout(() => { - this._collectCurrentChunk().catch(() => { - /* no catch */ - }); + this._collectCurrentChunk().catch(() => /* no catch */ {}); if (this._isRunning) { this._startProfilerInstance(); + + if (!this._profiler) { + // If restart failed, stop scheduling further chunks and reset context. + this._isRunning = false; + this._resetProfilerInfo(); + return; + } this._scheduleNextChunk(); } }, CHUNK_INTERVAL_MS); diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 699bc9c6e54b..550bb1cc9550 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -757,4 +757,16 @@ export function attachProfiledThreadToEvent(event: Event): void { ['thread.name']: PROFILER_THREAD_NAME, }, }; + + // Attach thread info to individual spans so that spans can be associated with the profiled thread on the UI even if contexts are missing. + if (Array.isArray(event.spans)) { + const spans = event.spans; + for (const span of spans) { + span.data = { + ...(span.data || {}), + ['thread.id']: PROFILER_THREAD_ID_STRING, + ['thread.name']: PROFILER_THREAD_NAME, + }; + } + } } diff --git a/packages/browser/test/profiling/integration.test.ts b/packages/browser/test/profiling/integration.test.ts index fca2c010e069..6f67c582fee8 100644 --- a/packages/browser/test/profiling/integration.test.ts +++ b/packages/browser/test/profiling/integration.test.ts @@ -101,7 +101,7 @@ describe('BrowserProfilingIntegration', () => { warnSpy.mockRestore(); }); - it("auto-sets profileLifecycle to 'trace' when not specified", async () => { + it("auto-sets profileLifecycle to 'manual' when not specified", async () => { Sentry.init({ dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', integrations: [Sentry.browserProfilingIntegration()], @@ -109,6 +109,6 @@ describe('BrowserProfilingIntegration', () => { const client = Sentry.getClient(); const lifecycle = (client?.getOptions() as any)?.profileLifecycle; - expect(lifecycle).toBe('trace'); + expect(lifecycle).toBe('manual'); }); }); From 5847efae6b4d24e4c5946372f59f6ee81d6cd784 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Thu, 18 Sep 2025 14:39:04 +0200 Subject: [PATCH 17/31] fix flakey test --- .../profiling/traceLifecycleMode_multiple-chunks/test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts index 226d948d443d..bfffd98fee44 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts @@ -33,14 +33,13 @@ sentryTest( } const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); - await page.goto(url); // 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( page, 2, - { envelopeType: 'profile_chunk', timeout: 5000 }, + { url, envelopeType: 'profile_chunk', timeout: 5000 }, properFullEnvelopeRequestParser, ); From 51231a451965a745ce9d9334b62296e84ee4985e Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Thu, 18 Sep 2025 14:40:13 +0200 Subject: [PATCH 18/31] add profile validation --- .../browser-integration-tests/package.json | 2 +- .../lifecycleMode/traceLifecycleProfiler.ts | 12 +++- packages/browser/src/profiling/utils.ts | 61 +++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 57b2b9f183de..122919420867 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -16,7 +16,7 @@ "postinstall": "yarn install-browsers", "pretest": "yarn clean && yarn type-check", "test": "yarn test:all --project='chromium'", - "test:all": "npx playwright test -c playwright.browser.config.ts", + "test:all": "npx playwright test -c playwright.browser.config.ts -g 'trace mode'", "test:bundle": "PW_BUNDLE=bundle yarn test", "test:bundle:min": "PW_BUNDLE=bundle_min yarn test", "test:bundle:replay": "PW_BUNDLE=bundle_replay yarn test", diff --git a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts index 118b5b6b03de..e531515ce672 100644 --- a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts +++ b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts @@ -12,7 +12,7 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { JSSelfProfiler } from '../jsSelfProfiling'; -import { createProfileChunkPayload, startJSSelfProfile } from '../utils'; +import { createProfileChunkPayload, startJSSelfProfile, validateProfileChunk } from '../utils'; const CHUNK_INTERVAL_MS = 60_000; @@ -269,10 +269,18 @@ export class BrowserTraceLifecycleProfiler { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const chunk = createProfileChunkPayload(profile, this._client!, this._profilerId); + // Validate chunk before sending + const { valid, reason } = validateProfileChunk(chunk); + if (!valid) { + DEBUG_BUILD && + debug.log('[Profiling] Discarding invalid profile chunk (this is probably a bug in the SDK):', reason); + return; + } + this._sendProfileChunk(chunk); DEBUG_BUILD && debug.log('[Profiling] Collected browser profile chunk.'); } catch (e) { - DEBUG_BUILD && debug.log('[Profiling] Error while stopping JS self profiler for chunk:', e); + DEBUG_BUILD && debug.log('[Profiling] Error while stopping JS Profiler for chunk:', e); } } diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 550bb1cc9550..239a81306cff 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -248,6 +248,67 @@ export function createProfileChunkPayload( }; } +/** + * Validate a profile chunk against the Sample Format V2 requirements. + * https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ + * - Presence of samples, stacks, frames + * - Required metadata fields + */ +export function validateProfileChunk(chunk: ProfileChunk): { valid: boolean; reason?: string } { + try { + // Required metadata + if (!chunk || typeof chunk !== 'object') { + return { valid: false, reason: 'chunk is not an object' }; + } + + // profiler_id and chunk_id must be 32 lowercase hex chars + const isHex32 = (val: unknown): boolean => typeof val === 'string' && /^[a-f0-9]{32}$/.test(val); + if (!isHex32(chunk.profiler_id)) { + return { valid: false, reason: 'missing or invalid profiler_id' }; + } + if (!isHex32(chunk.chunk_id)) { + return { valid: false, reason: 'missing or invalid chunk_id' }; + } + + // client_sdk name/version are required + if ( + !chunk.client_sdk || + typeof chunk.client_sdk.name !== 'string' || + typeof chunk.client_sdk.version !== 'string' + ) { + return { valid: false, reason: 'missing client_sdk metadata' }; + } + + if (typeof chunk.platform !== 'string') { + return { valid: false, reason: 'missing platform' }; + } + + if (typeof chunk.release !== 'string') { + return { valid: false, reason: 'missing release' }; + } + + // Profile data must have frames, stacks, samples + const profile = chunk.profile as { frames?: unknown[]; stacks?: unknown[]; samples?: unknown[] } | undefined; + if (!profile) { + return { valid: false, reason: 'missing profile data' }; + } + + if (!Array.isArray(profile.frames) || profile.frames.length === 0) { + return { valid: false, reason: 'profile has no frames' }; + } + if (!Array.isArray(profile.stacks) || profile.stacks.length === 0) { + return { valid: false, reason: 'profile has no stacks' }; + } + if (!Array.isArray(profile.samples) || profile.samples.length === 0) { + return { valid: false, reason: 'profile has no samples' }; + } + + return { valid: true }; + } catch (e) { + return { valid: false, reason: `unknown validation error: ${e}` }; + } +} + /** * Convert from JSSelfProfile format to ContinuousThreadCpuProfile format. */ From f32e0b575f7861601728da89eb32d7960c33ca35 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Thu, 18 Sep 2025 14:41:07 +0200 Subject: [PATCH 19/31] revert changing command --- dev-packages/browser-integration-tests/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 122919420867..57b2b9f183de 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -16,7 +16,7 @@ "postinstall": "yarn install-browsers", "pretest": "yarn clean && yarn type-check", "test": "yarn test:all --project='chromium'", - "test:all": "npx playwright test -c playwright.browser.config.ts -g 'trace mode'", + "test:all": "npx playwright test -c playwright.browser.config.ts", "test:bundle": "PW_BUNDLE=bundle yarn test", "test:bundle:min": "PW_BUNDLE=bundle_min yarn test", "test:bundle:replay": "PW_BUNDLE=bundle_replay yarn test", From ad44d5f8a2a0c0e21b37dccf97677ec354bae6a3 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Thu, 25 Sep 2025 17:04:25 +0200 Subject: [PATCH 20/31] review changes part 1 --- .../test.ts | 1 - .../subject.js | 1 - packages/browser/src/profiling/integration.ts | 4 +- .../lifecycleMode/traceLifecycleProfiler.ts | 63 ++++++++----------- packages/browser/src/profiling/utils.ts | 43 ++++++------- .../core/src/types-hoist/browseroptions.ts | 4 +- 6 files changed, 49 insertions(+), 67 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts index bfffd98fee44..bff0dee0c642 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts @@ -164,7 +164,6 @@ sentryTest( expect(envelopeItemPayload2.profile).toBeDefined(); expect(envelopeItemPayload2.version).toBe('2'); expect(envelopeItemPayload2.platform).toBe('javascript'); - expect(envelopeItemPayload2?.profile).toBeDefined(); // Required profile metadata (Sample Format V2) // https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js index bcf17a6649d9..071afe1ed059 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js @@ -48,6 +48,5 @@ await new Promise(r => setTimeout(r, 21)); firstSpan.end(); -// Ensure envelope flush const client = Sentry.getClient(); await client?.flush(5000); diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index e926049f148b..8213ff9f1b22 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -28,12 +28,12 @@ const _browserProfilingIntegration = (() => { setup(client) { const options = client.getOptions() as BrowserOptions; - if (options && !hasLegacyProfiling(options) && !options.profileLifecycle) { + if (!hasLegacyProfiling(options) && !options.profileLifecycle) { // Set default lifecycle mode options.profileLifecycle = 'manual'; } - if (!options || (hasLegacyProfiling(options) && !options.profilesSampleRate)) { + if (hasLegacyProfiling(options) && !options.profilesSampleRate) { DEBUG_BUILD && debug.log('[Profiling] Profiling disabled, no profiling options found.'); return; } diff --git a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts index e531515ce672..4f001856e67d 100644 --- a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts +++ b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts @@ -7,7 +7,6 @@ import { getGlobalScope, getRootSpan, getSdkMetadataForEnvelopeHeader, - spanToJSON, uuid4, } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; @@ -30,7 +29,6 @@ export class BrowserTraceLifecycleProfiler { private _client: Client | undefined; private _profiler: JSSelfProfiler | undefined; private _chunkTimer: ReturnType | undefined; - private _activeRootSpanCount: number; // For keeping track of active root spans private _activeRootSpanIds: Set; private _profilerId: string | undefined; @@ -41,7 +39,6 @@ export class BrowserTraceLifecycleProfiler { this._client = undefined; this._profiler = undefined; this._chunkTimer = undefined; - this._activeRootSpanCount = 0; this._activeRootSpanIds = new Set(); this._profilerId = undefined; this._isRunning = false; @@ -58,37 +55,36 @@ export class BrowserTraceLifecycleProfiler { this._sessionSampled = sessionSampled; client.on('spanStart', span => { - if (span !== getRootSpan(span)) { - return; - } if (!this._sessionSampled) { DEBUG_BUILD && debug.log('[Profiling] Session not sampled because of negative sampling decision.'); return; } + if (span !== getRootSpan(span)) { + return; + } // Only count sampled root spans if (!span.isRecording()) { DEBUG_BUILD && debug.log('[Profiling] Discarding profile because root span was not sampled.'); return; } - const rootSpanJSON = spanToJSON(span); - const spanId = rootSpanJSON.span_id as string | undefined; + const spanId = span.spanContext().spanId; if (!spanId) { return; } if (this._activeRootSpanIds.has(spanId)) { return; } + this._activeRootSpanIds.add(spanId); + const rootSpanCount = this._activeRootSpanIds.size; + + if (rootSpanCount === 1) { + DEBUG_BUILD && + debug.log( + `[Profiling] Root span with ID ${spanId} started. Will continue profiling for as long as there are active root spans (currently: ${rootSpanCount}).`, + ); - const wasZero = this._activeRootSpanCount === 0; - this._activeRootSpanCount++; // Increment before eventually starting the profiler - DEBUG_BUILD && - debug.log( - `[Profiling] Root span ${rootSpanJSON.description} started. Active root spans:`, - this._activeRootSpanCount, - ); - if (wasZero) { this.start(); } }); @@ -98,19 +94,19 @@ export class BrowserTraceLifecycleProfiler { return; } - const spanJSON = spanToJSON(span); - const spanId = spanJSON.span_id as string | undefined; + const spanId = span.spanContext().spanId; if (!spanId || !this._activeRootSpanIds.has(spanId)) { return; } this._activeRootSpanIds.delete(spanId); - this._activeRootSpanCount = Math.max(0, this._activeRootSpanCount - 1); + const rootSpanCount = this._activeRootSpanIds.size; + DEBUG_BUILD && debug.log( - `[Profiling] Root span ${spanJSON.description} ended. Active root spans: ${this._activeRootSpanCount}`, + `[Profiling] Root span with ID ${spanId} ended. Will continue profiling for as long as there are active root spans (currently: ${rootSpanCount}).`, ); - if (this._activeRootSpanCount === 0) { + if (rootSpanCount === 0) { this._collectCurrentChunk().catch(() => /* no catch */ {}); this.stop(); @@ -121,29 +117,24 @@ export class BrowserTraceLifecycleProfiler { /** * Handle an already-active root span at integration setup time. */ - public notifyRootSpanActive(span: Span): void { + public notifyRootSpanActive(rootSpan: Span): void { if (!this._sessionSampled) { return; } - if (span !== getRootSpan(span)) { - return; - } - const spanId = spanToJSON(span)?.span_id; + + const spanId = rootSpan.spanContext().spanId; if (!spanId || this._activeRootSpanIds.has(spanId)) { return; } - const wasZero = this._activeRootSpanCount === 0; - this._activeRootSpanIds.add(spanId); - this._activeRootSpanCount++; - DEBUG_BUILD && - debug.log( - '[Profiling] Detected already active root span during setup. Active root spans:', - this._activeRootSpanCount, - ); - - if (wasZero) { + + const rootSpanCount = this._activeRootSpanIds.size; + + if (rootSpanCount === 1) { + DEBUG_BUILD && + debug.log('[Profiling] Detected already active root span during setup. Active root spans now:', rootSpanCount); + this.start(); } } diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 239a81306cff..07fd0add6934 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -218,7 +218,8 @@ export function createProfileChunkPayload( client: Client, profilerId?: string, ): ProfileChunk { - if (jsSelfProfile === undefined || jsSelfProfile === null) { + // only == to catch null and undefined + if (jsSelfProfile == null) { throw new TypeError( `Cannot construct profiling event envelope without a valid profile. Got ${jsSelfProfile} instead.`, ); @@ -293,13 +294,13 @@ export function validateProfileChunk(chunk: ProfileChunk): { valid: boolean; rea return { valid: false, reason: 'missing profile data' }; } - if (!Array.isArray(profile.frames) || profile.frames.length === 0) { + if (!Array.isArray(profile.frames) || !profile.frames.length) { return { valid: false, reason: 'profile has no frames' }; } - if (!Array.isArray(profile.stacks) || profile.stacks.length === 0) { + if (!Array.isArray(profile.stacks) || !profile.stacks.length) { return { valid: false, reason: 'profile has no stacks' }; } - if (!Array.isArray(profile.samples) || profile.samples.length === 0) { + if (!Array.isArray(profile.samples) || !profile.samples.length) { return { valid: false, reason: 'profile has no samples' }; } @@ -701,7 +702,7 @@ export function shouldProfileSpanLegacy(span: Span): boolean { /** * Determine if a profile should be created for the current session (lifecycle profiling mode). */ -export function shouldProfileSession(options?: BrowserOptions): boolean { +export function shouldProfileSession(options: BrowserOptions): boolean { // If constructor failed once, it will always fail, so we can early return. if (PROFILING_CONSTRUCTOR_FAILED) { if (DEBUG_BUILD) { @@ -710,16 +711,12 @@ export function shouldProfileSession(options?: BrowserOptions): boolean { return false; } - if (!options || options.profileLifecycle !== 'trace') { + if (options.profileLifecycle !== 'trace') { return false; } // Session sampling: profileSessionSampleRate gates whether profiling is enabled for this session - const profileSessionSampleRate: number | boolean | undefined = ( - options as unknown as { - profileSessionSampleRate?: number | boolean; - } - ).profileSessionSampleRate; + const profileSessionSampleRate = options.profileSessionSampleRate; if (!isValidSampleRate(profileSessionSampleRate)) { DEBUG_BUILD && debug.warn('[Profiling] Discarding profile because of invalid profileSessionSampleRate.'); @@ -732,15 +729,14 @@ export function shouldProfileSession(options?: BrowserOptions): boolean { return false; } - return profileSessionSampleRate === true ? true : Math.random() <= profileSessionSampleRate; + return Math.random() <= profileSessionSampleRate; } /** * Checks if legacy profiling is configured. */ -export function hasLegacyProfiling(options: BrowserOptions = {} as unknown as BrowserOptions): boolean { - // eslint-disable-next-line deprecation/deprecation - return typeof (options as unknown as { profilesSampleRate?: number | boolean }).profilesSampleRate !== 'undefined'; +export function hasLegacyProfiling(options: BrowserOptions): boolean { + return typeof options.profilesSampleRate !== 'undefined'; } /** @@ -820,14 +816,11 @@ export function attachProfiledThreadToEvent(event: Event): void { }; // Attach thread info to individual spans so that spans can be associated with the profiled thread on the UI even if contexts are missing. - if (Array.isArray(event.spans)) { - const spans = event.spans; - for (const span of spans) { - span.data = { - ...(span.data || {}), - ['thread.id']: PROFILER_THREAD_ID_STRING, - ['thread.name']: PROFILER_THREAD_NAME, - }; - } - } + event.spans?.forEach(span => { + span.data = { + ...(span.data || {}), + ['thread.id']: PROFILER_THREAD_ID_STRING, + ['thread.name']: PROFILER_THREAD_NAME, + }; + }); } diff --git a/packages/core/src/types-hoist/browseroptions.ts b/packages/core/src/types-hoist/browseroptions.ts index 20bd3816eed3..18bbd46af09c 100644 --- a/packages/core/src/types-hoist/browseroptions.ts +++ b/packages/core/src/types-hoist/browseroptions.ts @@ -28,8 +28,8 @@ export type BrowserClientProfilingOptions = { /** * Sets profiling session sample rate for the entire profiling session. * - * A profiling session corresponds to a user session, so this rate determines what percentage of user sessions will have profiling enabled. - * + * A profiling session corresponds to a user session, meaning it is set once at integration initialization and + * persisted until the next page reload. This rate determines what percentage of user sessions will have profiling enabled. * @default 0 */ profileSessionSampleRate?: number; From cc7461d2cd8275270ce6327147cf5eccaa9c916e Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 30 Sep 2025 10:57:01 +0200 Subject: [PATCH 21/31] add timeout kill-switch for each root span --- .../lifecycleMode/traceLifecycleProfiler.ts | 56 ++++++- .../test/profiling/traceLifecycle.test.ts | 157 ++++++++++++++++++ 2 files changed, 209 insertions(+), 4 deletions(-) diff --git a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts index 4f001856e67d..166271074669 100644 --- a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts +++ b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts @@ -13,7 +13,9 @@ import { DEBUG_BUILD } from '../../debug-build'; import type { JSSelfProfiler } from '../jsSelfProfiling'; import { createProfileChunkPayload, startJSSelfProfile, validateProfileChunk } from '../utils'; -const CHUNK_INTERVAL_MS = 60_000; +const CHUNK_INTERVAL_MS = 60_000; // 1 minute +// Maximum length for trace lifecycle profiling per root span (e.g. if spanEnd never fires) +const MAX_ROOT_SPAN_PROFILE_MS = 300_000; // 5 minutes /** * Browser trace-lifecycle profiler (UI Profiling / Profiling V2): @@ -31,6 +33,7 @@ export class BrowserTraceLifecycleProfiler { private _chunkTimer: ReturnType | undefined; // For keeping track of active root spans private _activeRootSpanIds: Set; + private _rootSpanTimeouts: Map>; private _profilerId: string | undefined; private _isRunning: boolean; private _sessionSampled: boolean; @@ -40,6 +43,7 @@ export class BrowserTraceLifecycleProfiler { this._profiler = undefined; this._chunkTimer = undefined; this._activeRootSpanIds = new Set(); + this._rootSpanTimeouts = new Map>(); this._profilerId = undefined; this._isRunning = false; this._sessionSampled = false; @@ -79,6 +83,11 @@ export class BrowserTraceLifecycleProfiler { this._activeRootSpanIds.add(spanId); const rootSpanCount = this._activeRootSpanIds.size; + const timeout = setTimeout(() => { + this._onRootSpanTimeout(spanId); + }, MAX_ROOT_SPAN_PROFILE_MS); + this._rootSpanTimeouts.set(spanId, timeout); + if (rootSpanCount === 1) { DEBUG_BUILD && debug.log( @@ -168,7 +177,7 @@ export class BrowserTraceLifecycleProfiler { return; } - this._scheduleNextChunk(); + this._startPeriodicChunking(); } /** @@ -185,6 +194,8 @@ export class BrowserTraceLifecycleProfiler { this._chunkTimer = undefined; } + this._clearAllRootSpanTimeouts(); + // Collect whatever was currently recording this._collectCurrentChunk() .catch(() => /* no catch */ {}) @@ -201,6 +212,14 @@ export class BrowserTraceLifecycleProfiler { getGlobalScope().setContext('profile', {}); } + /** + * Clear and reset all per-root-span timeouts. + */ + private _clearAllRootSpanTimeouts(): void { + this._rootSpanTimeouts.forEach(timeout => clearTimeout(timeout)); + this._rootSpanTimeouts.clear(); + } + /** * Start a profiler instance if needed. */ @@ -221,7 +240,7 @@ export class BrowserTraceLifecycleProfiler { * Each tick collects a chunk and restarts the profiler. * A chunk should be closed when there are no active root spans anymore OR when the maximum chunk interval is reached. */ - private _scheduleNextChunk(): void { + private _startPeriodicChunking(): void { if (!this._isRunning) { return; } @@ -238,11 +257,40 @@ export class BrowserTraceLifecycleProfiler { this._resetProfilerInfo(); return; } - this._scheduleNextChunk(); + + this._startPeriodicChunking(); } }, CHUNK_INTERVAL_MS); } + /** + * Handle timeout for a specific root span ID to avoid indefinitely running profiler if `spanEnd` never fires. + * If this was the last active root span, collect the current chunk and stop profiling. + */ + private _onRootSpanTimeout(rootSpanId: string): void { + // If span already ended, ignore + if (!this._rootSpanTimeouts.has(rootSpanId)) { + return; + } + this._rootSpanTimeouts.delete(rootSpanId); + + if (!this._activeRootSpanIds.has(rootSpanId)) { + return; + } + + DEBUG_BUILD && + debug.log( + `[Profiling] Reached 5-minute timeout for root span ${rootSpanId}. You likely started a manual root span that never called \`.end()\`.`, + ); + + this._activeRootSpanIds.delete(rootSpanId); + + const rootSpanCount = this._activeRootSpanIds.size; + if (rootSpanCount === 0) { + this.stop(); + } + } + /** * Stop the current profiler, convert and send a profile chunk. */ diff --git a/packages/browser/test/profiling/traceLifecycle.test.ts b/packages/browser/test/profiling/traceLifecycle.test.ts index f8d61bc8b7ed..fce7539e474e 100644 --- a/packages/browser/test/profiling/traceLifecycle.test.ts +++ b/packages/browser/test/profiling/traceLifecycle.test.ts @@ -3,6 +3,7 @@ */ import * as Sentry from '@sentry/browser'; +import type { Span } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; describe('Browser Profiling v2 trace lifecycle', () => { @@ -188,6 +189,162 @@ describe('Browser Profiling v2 trace lifecycle', () => { spanRef.end(); await Promise.resolve(); }); + + it('emits periodic chunks every 60s while span is stuck (no spanEnd)', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanRef: any; + Sentry.startSpanManual({ name: 'root-interval', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + // Advance timers by 60s to trigger first periodic chunk while still running + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + + // One chunk sent and profiler restarted for the next period + expect(stop.mock.calls.length).toBe(1); + expect(send.mock.calls.length).toBe(1); + expect(mockConstructor.mock.calls.length).toBe(2); + const firstChunkHeader = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + expect(firstChunkHeader?.type).toBe('profile_chunk'); + + // Second chunk after another 60s + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + expect(stop.mock.calls.length).toBe(2); + expect(send.mock.calls.length).toBe(2); + expect(mockConstructor.mock.calls.length).toBe(3); + + // Third chunk after another 60s + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + expect(stop.mock.calls.length).toBe(3); + expect(send.mock.calls.length).toBe(3); + expect(mockConstructor.mock.calls.length).toBe(4); + + spanRef.end(); + vi.advanceTimersByTime(100_000); + await Promise.resolve(); + + // All chunks should have been sent (4 total) + expect(stop.mock.calls.length).toBe(4); + expect(mockConstructor.mock.calls.length).toBe(4); // still 4 + expect(send.mock.calls.length).toBe(5); // 4 chunks + 1 transaction (tested below) + + const countProfileChunks = send.mock.calls.filter(obj => obj?.[0]?.[1]?.[0]?.[0].type === 'profile_chunk').length; + const countTransactions = send.mock.calls.filter(obj => obj?.[0]?.[1]?.[0]?.[0].type === 'transaction').length; + expect(countProfileChunks).toBe(4); + expect(countTransactions).toBe(1); + }); + + it('emits periodic chunks and stops after timeout if manual root span never ends', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpanManual({ name: 'root-manual-never-ends', parentSpan: null, forceTransaction: true }, _span => { + // keep open - don't end + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + // Creates 2 profile chunks + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + + // At least two chunks emitted and profiler restarted in between + const stopsBeforeKill = stop.mock.calls.length; + const sendsBeforeKill = send.mock.calls.length; + const constructorCallsBeforeKill = mockConstructor.mock.calls.length; + expect(stopsBeforeKill).toBe(2); + expect(sendsBeforeKill).toBe(2); + expect(constructorCallsBeforeKill).toBe(3); + + // Advance to session kill switch (~5 minutes total since start) + vi.advanceTimersByTime(180_000); // now 300s total + await Promise.resolve(); + + const stopsAtKill = stop.mock.calls.length; + const sendsAtKill = send.mock.calls.length; + const constructorCallsAtKill = mockConstructor.mock.calls.length; + // 5min/60sec interval = 5 send/stop calls and 5 calls of constructor total + expect(constructorCallsAtKill).toBe(constructorCallsBeforeKill + 2); // constructor was already called 3 times + expect(stopsAtKill).toBe(stopsBeforeKill + 3); + expect(sendsAtKill).toBe(sendsBeforeKill + 3); + + // No calls should happen after kill + vi.advanceTimersByTime(120_000); + await Promise.resolve(); + expect(stop.mock.calls.length).toBe(stopsAtKill); + expect(send.mock.calls.length).toBe(sendsAtKill); + expect(mockConstructor.mock.calls.length).toBe(constructorCallsAtKill); + }); + + it('continues profiling for another rootSpan after one rootSpan profile timed-out', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpanManual({ name: 'root-manual-never-ends', parentSpan: null, forceTransaction: true }, _span => { + // keep open - don't end + }); + + vi.advanceTimersByTime(300_000); // 5 minutes (kill switch) + await Promise.resolve(); + + const stopsAtKill = stop.mock.calls.length; + const sendsAtKill = send.mock.calls.length; + const constructorCallsAtKill = mockConstructor.mock.calls.length; + // 5min/60sec interval = 5 send/stop calls and 5 calls of constructor total + expect(constructorCallsAtKill).toBe(5); + expect(stopsAtKill).toBe(5); + expect(sendsAtKill).toBe(5); + + let spanRef: Span | undefined; + Sentry.startSpanManual({ name: 'root-manual-will-end', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + vi.advanceTimersByTime(119_000); // create 2 chunks + await Promise.resolve(); + + spanRef?.end(); + + expect(mockConstructor.mock.calls.length).toBe(sendsAtKill + 2); + expect(stop.mock.calls.length).toBe(constructorCallsAtKill + 2); + expect(send.mock.calls.length).toBe(stopsAtKill + 2); + }); }); it('sets global profile context on transaction', async () => { From e189867e9448933f6962ef9ea25de7dca807dceb Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 30 Sep 2025 13:12:03 +0200 Subject: [PATCH 22/31] keep same profiler ID over one session --- .../lifecycleMode/traceLifecycleProfiler.ts | 31 +- .../test/profiling/traceLifecycle.test.ts | 322 +++++++++++++++--- 2 files changed, 280 insertions(+), 73 deletions(-) diff --git a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts index 166271074669..41efca7897c7 100644 --- a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts +++ b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts @@ -34,6 +34,7 @@ export class BrowserTraceLifecycleProfiler { // For keeping track of active root spans private _activeRootSpanIds: Set; private _rootSpanTimeouts: Map>; + // ID for Profiler session private _profilerId: string | undefined; private _isRunning: boolean; private _sessionSampled: boolean; @@ -53,6 +54,9 @@ export class BrowserTraceLifecycleProfiler { * Initialize the profiler with client and session sampling decision computed by the integration. */ public initialize(client: Client, sessionSampled: boolean): void { + // One Profiler ID per profiling session (user session) + this._profilerId = uuid4(); + DEBUG_BUILD && debug.log("[Profiling] Initializing profiler (lifecycle='trace')."); this._client = client; @@ -72,6 +76,11 @@ export class BrowserTraceLifecycleProfiler { return; } + // Matching root spans with profiles + getGlobalScope().setContext('profile', { + profiler_id: this._profilerId, + }); + const spanId = span.spanContext().spanId; if (!spanId) { return; @@ -155,16 +164,7 @@ export class BrowserTraceLifecycleProfiler { if (this._isRunning) { return; } - this._isRunning = true; - if (!this._profilerId) { - this._profilerId = uuid4(); - - // Matching root spans with profiles - getGlobalScope().setContext('profile', { - profiler_id: this._profilerId, - }); - } DEBUG_BUILD && debug.log('[Profiling] Started profiling with profile ID:', this._profilerId); @@ -172,7 +172,6 @@ export class BrowserTraceLifecycleProfiler { if (!this._profiler) { DEBUG_BUILD && debug.log('[Profiling] Stopping trace lifecycle profiling.'); - this._isRunning = false; this._resetProfilerInfo(); return; } @@ -197,18 +196,14 @@ export class BrowserTraceLifecycleProfiler { this._clearAllRootSpanTimeouts(); // Collect whatever was currently recording - this._collectCurrentChunk() - .catch(() => /* no catch */ {}) - .finally(() => { - this._resetProfilerInfo(); - }); + this._collectCurrentChunk().catch(() => /* no catch */ {}); } /** - * Resets profiling information from scope and class instance. + * Resets profiling information from scope and resets running state */ private _resetProfilerInfo(): void { - this._profilerId = undefined; + this._isRunning = false; getGlobalScope().setContext('profile', {}); } @@ -253,7 +248,6 @@ export class BrowserTraceLifecycleProfiler { if (!this._profiler) { // If restart failed, stop scheduling further chunks and reset context. - this._isRunning = false; this._resetProfilerInfo(); return; } @@ -317,6 +311,7 @@ export class BrowserTraceLifecycleProfiler { } this._sendProfileChunk(chunk); + DEBUG_BUILD && debug.log('[Profiling] Collected browser profile chunk.'); } catch (e) { DEBUG_BUILD && debug.log('[Profiling] Error while stopping JS Profiler for chunk:', e); diff --git a/packages/browser/test/profiling/traceLifecycle.test.ts b/packages/browser/test/profiling/traceLifecycle.test.ts index fce7539e474e..f28880960256 100644 --- a/packages/browser/test/profiling/traceLifecycle.test.ts +++ b/packages/browser/test/profiling/traceLifecycle.test.ts @@ -347,73 +347,285 @@ describe('Browser Profiling v2 trace lifecycle', () => { }); }); - it('sets global profile context on transaction', async () => { - // Use real timers to avoid interference with scheduled chunk timer - vi.useRealTimers(); + describe('profile context', () => { + it('sets global profile context on transaction', async () => { + vi.useRealTimers(); - const stop = vi.fn().mockResolvedValue({ - frames: [{ name: 'f' }], - stacks: [{ frameId: 0 }], - samples: [{ timestamp: 0 }, { timestamp: 10 }], - resources: [], + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpan({ name: 'root-for-context', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + // Allow async tasks to resolve and flush queued envelopes + const client = Sentry.getClient(); + await client?.flush(1000); + + // Find the transaction envelope among sent envelopes + const calls = send.mock.calls; + const txnCall = calls.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction'); + expect(txnCall).toBeDefined(); + + const transaction = txnCall?.[0]?.[1]?.[0]?.[1]; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + data: expect.objectContaining({ + ['thread.id']: expect.any(String), + ['thread.name']: expect.any(String), + }), + }, + profile: { + profiler_id: expect.any(String), + }, + }, + }); }); - class MockProfilerImpl { - stopped: boolean = false; - constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} - stop() { - this.stopped = true; - return stop(); + it('reuses the same profiler_id across multiple root transactions within one session', async () => { + vi.useRealTimers(); + + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} } - addEventListener() {} - } - (window as any).Profiler = vi - .fn() - .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); - const send = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); - Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpan({ name: 'rootSpan-1', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + Sentry.startSpan({ name: 'rootSpan-2', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + await Sentry.getClient()?.flush(1000); + + const calls = send.mock.calls; + const transactionEvents = calls + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + expect(transactionEvents.length).toEqual(2); + + const firstProfilerId = transactionEvents[0]?.contexts?.profile?.profiler_id; + const secondProfilerId = transactionEvents[1]?.contexts?.profile?.profiler_id; + + expect(typeof firstProfilerId).toBe('string'); + expect(typeof secondProfilerId).toBe('string'); + expect(firstProfilerId).toBe(secondProfilerId); }); - let spanRef: any; - Sentry.startSpanManual({ name: 'root-for-context', parentSpan: null, forceTransaction: true }, span => { - spanRef = span; + it('emits profile_chunk items with the same profiler_id as the transactions within a session', async () => { + vi.useRealTimers(); + + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpan({ name: 'rootSpan-chunk-1', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + Sentry.startSpan({ name: 'rootSpan-chunk-2', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + await Sentry.getClient()?.flush(1000); + + const calls = send.mock.calls; + const transactionEvents = calls + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + expect(transactionEvents.length).toBe(2); + const expectedProfilerId = transactionEvents[0]?.contexts?.profile?.profiler_id; + expect(typeof expectedProfilerId).toBe('string'); + + const profileChunks = calls + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'profile_chunk') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + expect(profileChunks.length).toBe(2); + + for (const chunk of profileChunks) { + expect(chunk?.profiler_id).toBe(expectedProfilerId); + } }); - // End span to trigger sending of the transaction - spanRef.end(); + it('changes profiler_id when a new user session starts (new SDK init)', async () => { + vi.useRealTimers(); - // Allow async tasks to resolve and flush queued envelopes - const client = Sentry.getClient(); - await client?.flush(1000); - - // Find the transaction envelope among sent envelopes - const calls = send.mock.calls; - const txnCall = calls.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction'); - expect(txnCall).toBeDefined(); - - const transaction = txnCall?.[0]?.[1]?.[0]?.[1]; - - expect(transaction).toMatchObject({ - contexts: { - trace: { - data: expect.objectContaining({ - ['thread.id']: expect.any(String), - ['thread.name']: expect.any(String), - }), - }, - profile: { - profiler_id: expect.any(String), - }, - }, + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + // Session 1 + const send1 = vi.fn().mockResolvedValue(undefined); + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: send1 }), + }); + + Sentry.startSpan({ name: 'session-1-rootSpan', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + let client = Sentry.getClient(); + await client?.flush(1000); + + // Extract first session profiler_id from transaction and a chunk + const calls1 = send1.mock.calls; + const txnEvt1 = calls1.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction')?.[0]?.[1]?.[0]?.[1]; + const chunks1 = calls1 + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'profile_chunk') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + const profilerId1 = txnEvt1?.contexts?.profile?.profiler_id as string | undefined; + expect(typeof profilerId1).toBe('string'); + expect(chunks1.length).toBe(1); + for (const chunk of chunks1) { + expect(chunk?.profiler_id).toBe(profilerId1); + } + + // End Session 1 + await client?.close(); + + // Session 2 (new init simulates new user session) + const send2 = vi.fn().mockResolvedValue(undefined); + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: send2 }), + }); + + Sentry.startSpan({ name: 'session-2-rootSpan', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + client = Sentry.getClient(); + await client?.flush(1000); + + const calls2 = send2.mock.calls; + const txnEvt2 = calls2.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction')?.[0]?.[1]?.[0]?.[1]; + const chunks2 = calls2 + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'profile_chunk') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + const profilerId2 = txnEvt2?.contexts?.profile?.profiler_id as string | undefined; + expect(typeof profilerId2).toBe('string'); + expect(profilerId2).not.toBe(profilerId1); + expect(chunks2.length).toBe(1); + for (const chunk of chunks2) { + expect(chunk?.profiler_id).toBe(profilerId2); + } }); }); }); From 3e8497eff8c334331e3d3600280fadcfa2d1f42d Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 30 Sep 2025 13:12:18 +0200 Subject: [PATCH 23/31] add size limit --- .size-limit.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.size-limit.js b/.size-limit.js index 8eef4950f00d..486124b1baa3 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -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: '80 KB', + }, { name: '@sentry/browser (incl. Tracing, Replay)', path: 'packages/browser/build/npm/esm/index.js', From bbf079474a6c7a0d6956d013fb9f61ce6295d06d Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 30 Sep 2025 13:13:01 +0200 Subject: [PATCH 24/31] rename test file --- .../{traceLifecycle.test.ts => traceLifecycleProfiler.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/browser/test/profiling/{traceLifecycle.test.ts => traceLifecycleProfiler.test.ts} (100%) diff --git a/packages/browser/test/profiling/traceLifecycle.test.ts b/packages/browser/test/profiling/traceLifecycleProfiler.test.ts similarity index 100% rename from packages/browser/test/profiling/traceLifecycle.test.ts rename to packages/browser/test/profiling/traceLifecycleProfiler.test.ts From d9b83887c6441d0fa7c1d7bd1b967b29316bcb41 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 30 Sep 2025 13:20:58 +0200 Subject: [PATCH 25/31] shorten chunk validation function --- packages/browser/src/profiling/utils.ts | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 07fd0add6934..ea3e1e41d6d0 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -33,7 +33,8 @@ const MS_TO_NS = 1e6; // Checking if we are in Main or Worker thread: `self` (not `window`) is the `globalThis` in Web Workers and `importScripts` are only available in Web Workers const isMainThread = 'window' in GLOBAL_OBJ && GLOBAL_OBJ.window === GLOBAL_OBJ && typeof importScripts === 'undefined'; -export const PROFILER_THREAD_ID_STRING = String(0); // todo: ID for Web Worker threads +// Setting ID to 0 as we cannot get an ID from Web Workers +export const PROFILER_THREAD_ID_STRING = String(0); export const PROFILER_THREAD_NAME = isMainThread ? 'main' : 'worker'; // We force make this optional to be on the safe side... @@ -271,23 +272,10 @@ export function validateProfileChunk(chunk: ProfileChunk): { valid: boolean; rea return { valid: false, reason: 'missing or invalid chunk_id' }; } - // client_sdk name/version are required - if ( - !chunk.client_sdk || - typeof chunk.client_sdk.name !== 'string' || - typeof chunk.client_sdk.version !== 'string' - ) { + if (!chunk.client_sdk) { return { valid: false, reason: 'missing client_sdk metadata' }; } - if (typeof chunk.platform !== 'string') { - return { valid: false, reason: 'missing platform' }; - } - - if (typeof chunk.release !== 'string') { - return { valid: false, reason: 'missing release' }; - } - // Profile data must have frames, stacks, samples const profile = chunk.profile as { frames?: unknown[]; stacks?: unknown[]; samples?: unknown[] } | undefined; if (!profile) { From 918237ef8e75e6ce05bcb6341731dfba0f014798 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 30 Sep 2025 13:21:33 +0200 Subject: [PATCH 26/31] add catch error messages --- .../lifecycleMode/traceLifecycleProfiler.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts index 41efca7897c7..86c652c454a4 100644 --- a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts +++ b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts @@ -125,7 +125,9 @@ export class BrowserTraceLifecycleProfiler { `[Profiling] Root span with ID ${spanId} ended. Will continue profiling for as long as there are active root spans (currently: ${rootSpanCount}).`, ); if (rootSpanCount === 0) { - this._collectCurrentChunk().catch(() => /* no catch */ {}); + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `spanEnd`:', e); + }); this.stop(); } @@ -196,7 +198,9 @@ export class BrowserTraceLifecycleProfiler { this._clearAllRootSpanTimeouts(); // Collect whatever was currently recording - this._collectCurrentChunk().catch(() => /* no catch */ {}); + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `stop()`:', e); + }); } /** @@ -241,7 +245,9 @@ export class BrowserTraceLifecycleProfiler { } this._chunkTimer = setTimeout(() => { - this._collectCurrentChunk().catch(() => /* no catch */ {}); + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk during periodic chunking:', e); + }); if (this._isRunning) { this._startProfilerInstance(); From b845030d3d644292fb6dcb4cdc548a7e38c68446 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 30 Sep 2025 13:47:15 +0200 Subject: [PATCH 27/31] use processEvent instead of beforeSendEvent --- .size-limit.js | 2 +- packages/browser/src/profiling/integration.ts | 7 +++---- packages/browser/src/profiling/utils.ts | 8 +++++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 486124b1baa3..dbe18139c0b4 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -45,7 +45,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'browserProfilingIntegration'), gzip: true, - limit: '80 KB', + limit: '48 KB', }, { name: '@sentry/browser (incl. Tracing, Replay)', diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 8213ff9f1b22..415282698d45 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -24,7 +24,6 @@ const INTEGRATION_NAME = 'BrowserProfiling'; const _browserProfilingIntegration = (() => { return { name: INTEGRATION_NAME, - // eslint-disable-next-line complexity setup(client) { const options = client.getOptions() as BrowserOptions; @@ -83,9 +82,6 @@ const _browserProfilingIntegration = (() => { } }, 0); } - - // Adding client hook to attach profiles to transaction events before they are sent. - client.on('beforeSendEvent', attachProfiledThreadToEvent); } else { // LEGACY PROFILING (v1) if (rootSpan && isAutomatedPageLoadSpan(rootSpan)) { @@ -154,6 +150,9 @@ const _browserProfilingIntegration = (() => { }); } }, + processEvent(event) { + return attachProfiledThreadToEvent(event); + }, }; }) satisfies IntegrationFn; diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index ea3e1e41d6d0..296a4d6bd96f 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -783,13 +783,13 @@ export function addProfileToGlobalCache(profile_id: string, profile: JSSelfProfi /** * Attaches the profiled thread information to the event's trace context. */ -export function attachProfiledThreadToEvent(event: Event): void { +export function attachProfiledThreadToEvent(event: Event): Event { if (!event?.contexts?.profile) { - return; + return event; } if (!event.contexts) { - return; + return event; } // @ts-expect-error the trace fallback value is wrong, though it should never happen @@ -811,4 +811,6 @@ export function attachProfiledThreadToEvent(event: Event): void { ['thread.name']: PROFILER_THREAD_NAME, }; }); + + return event; } From 5e7efe32401b72c1e1272de106f7640c846ea41e Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 30 Sep 2025 14:12:19 +0200 Subject: [PATCH 28/31] fix test flakiness --- .../suites/profiling/legacyMode/test.ts | 16 +++++++++------- .../traceLifecycleMode_multiple-chunks/test.ts | 16 +++++++++------- .../traceLifecycleMode_overlapping-spans/test.ts | 16 +++++++++------- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts index 35f4e17bec0a..d473236cdfda 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts @@ -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 !== ''); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts index bff0dee0c642..702140b8823e 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts @@ -110,14 +110,16 @@ sentryTest( expect(profile1.frames.length).toBeGreaterThan(0); for (const frame of profile1.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 = profile1.frames.map(frame => frame.function).filter(name => name !== ''); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts index addece6875d1..60744def96cd 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts @@ -112,14 +112,16 @@ sentryTest( 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 !== ''); From 765f89de7de8b262daaaafe7cbcdfba66cea9f18 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Tue, 30 Sep 2025 14:15:05 +0200 Subject: [PATCH 29/31] fix type lint --- packages/browser/test/profiling/integration.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/browser/test/profiling/integration.test.ts b/packages/browser/test/profiling/integration.test.ts index aeda2f397a01..f9d97230701c 100644 --- a/packages/browser/test/profiling/integration.test.ts +++ b/packages/browser/test/profiling/integration.test.ts @@ -5,6 +5,7 @@ import * as Sentry from '@sentry/browser'; import { debug } from '@sentry/core'; import { describe, expect, it, vi } from 'vitest'; +import type { BrowserClient } from '../../src/index'; import type { JSSelfProfile } from '../../src/profiling/jsSelfProfiling'; describe('BrowserProfilingIntegration', () => { @@ -105,7 +106,7 @@ describe('BrowserProfilingIntegration', () => { }); const client = Sentry.getClient(); - const lifecycle = (client?.getOptions() as any)?.profileLifecycle; + const lifecycle = client?.getOptions()?.profileLifecycle; expect(lifecycle).toBe('manual'); }); }); From 537b85260c525525d25258ee45601ddfe51dad11 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Mon, 20 Oct 2025 16:54:31 +0200 Subject: [PATCH 30/31] bundle size improvment (review comment) --- .../lifecycleMode/traceLifecycleProfiler.ts | 9 ++++++--- packages/browser/src/profiling/utils.ts | 20 +++++++++---------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts index 86c652c454a4..3ce773fe01ff 100644 --- a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts +++ b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts @@ -309,10 +309,13 @@ export class BrowserTraceLifecycleProfiler { const chunk = createProfileChunkPayload(profile, this._client!, this._profilerId); // Validate chunk before sending - const { valid, reason } = validateProfileChunk(chunk); - if (!valid) { + const validationReturn = validateProfileChunk(chunk); + if ('reason' in validationReturn) { DEBUG_BUILD && - debug.log('[Profiling] Discarding invalid profile chunk (this is probably a bug in the SDK):', reason); + debug.log( + '[Profiling] Discarding invalid profile chunk (this is probably a bug in the SDK):', + validationReturn.reason, + ); return; } diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 296a4d6bd96f..ed794a40a98b 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -256,45 +256,45 @@ export function createProfileChunkPayload( * - Presence of samples, stacks, frames * - Required metadata fields */ -export function validateProfileChunk(chunk: ProfileChunk): { valid: boolean; reason?: string } { +export function validateProfileChunk(chunk: ProfileChunk): { valid: true } | { reason: string } { try { // Required metadata if (!chunk || typeof chunk !== 'object') { - return { valid: false, reason: 'chunk is not an object' }; + return { reason: 'chunk is not an object' }; } // profiler_id and chunk_id must be 32 lowercase hex chars const isHex32 = (val: unknown): boolean => typeof val === 'string' && /^[a-f0-9]{32}$/.test(val); if (!isHex32(chunk.profiler_id)) { - return { valid: false, reason: 'missing or invalid profiler_id' }; + return { reason: 'missing or invalid profiler_id' }; } if (!isHex32(chunk.chunk_id)) { - return { valid: false, reason: 'missing or invalid chunk_id' }; + return { reason: 'missing or invalid chunk_id' }; } if (!chunk.client_sdk) { - return { valid: false, reason: 'missing client_sdk metadata' }; + return { reason: 'missing client_sdk metadata' }; } // Profile data must have frames, stacks, samples const profile = chunk.profile as { frames?: unknown[]; stacks?: unknown[]; samples?: unknown[] } | undefined; if (!profile) { - return { valid: false, reason: 'missing profile data' }; + return { reason: 'missing profile data' }; } if (!Array.isArray(profile.frames) || !profile.frames.length) { - return { valid: false, reason: 'profile has no frames' }; + return { reason: 'profile has no frames' }; } if (!Array.isArray(profile.stacks) || !profile.stacks.length) { - return { valid: false, reason: 'profile has no stacks' }; + return { reason: 'profile has no stacks' }; } if (!Array.isArray(profile.samples) || !profile.samples.length) { - return { valid: false, reason: 'profile has no samples' }; + return { reason: 'profile has no samples' }; } return { valid: true }; } catch (e) { - return { valid: false, reason: `unknown validation error: ${e}` }; + return { reason: `unknown validation error: ${e}` }; } } From fcf5609403e5ccd8a6ea3f3412c6980f91d571f2 Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Mon, 20 Oct 2025 17:17:26 +0200 Subject: [PATCH 31/31] remove not needed rule --- packages/browser/src/profiling/utils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 301f01ca4e29..ed794a40a98b 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -773,8 +773,7 @@ export function addProfileToGlobalCache(profile_id: string, profile: JSSelfProfi PROFILE_MAP.set(profile_id, profile); if (PROFILE_MAP.size > 30) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const last = PROFILE_MAP.keys().next().value!; + const last = PROFILE_MAP.keys().next().value; if (last !== undefined) { PROFILE_MAP.delete(last); }