diff --git a/.size-limit.js b/.size-limit.js index 5de4268a53d6..9cebd30285e4 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -40,6 +40,13 @@ module.exports = [ gzip: true, limit: '41 KB', }, + { + name: '@sentry/browser (incl. Tracing, Profiling)', + path: 'packages/browser/build/npm/esm/index.js', + import: createImport('init', 'browserTracingIntegration', 'browserProfilingIntegration'), + gzip: true, + limit: '48 KB', + }, { name: '@sentry/browser (incl. Tracing, Replay)', path: 'packages/browser/build/npm/esm/index.js', 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/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/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js new file mode 100644 index 000000000000..0095eb5743d9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/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, + profileLifecycle: 'trace', +}); + +function largeSum(amount = 1000000) { + let sum = 0; + for (let i = 0; i < amount; i++) { + sum += Math.sqrt(i) * Math.sin(i); + } +} + +function fibonacci(n) { + if (n <= 1) { + return n; + } + return fibonacci(n - 1) + fibonacci(n - 2); +} + +// Create two NON-overlapping root spans so that the profiler stops and emits a chunk +// after each span (since active root span count returns to 0 between them). +await Sentry.startSpanManual({ name: 'root-fibonacci-1', parentSpan: null, forceTransaction: true }, async span => { + fibonacci(40); + // Ensure we cross the sampling interval to avoid flakes + await new Promise(resolve => setTimeout(resolve, 25)); + span.end(); +}); + +// Small delay to ensure the first chunk is collected and sent +await new Promise(r => setTimeout(r, 25)); + +await Sentry.startSpanManual({ name: 'root-largeSum-2', parentSpan: null, forceTransaction: true }, async span => { + largeSum(); + // Ensure we cross the sampling interval to avoid flakes + await new Promise(resolve => setTimeout(resolve, 25)); + span.end(); +}); + +const client = Sentry.getClient(); +await client?.flush(5000); 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 new file mode 100644 index 000000000000..702140b8823e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts @@ -0,0 +1,206 @@ +import { expect } from '@playwright/test'; +import type { ProfileChunkEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + countEnvelopes, + getMultipleSentryEnvelopeRequests, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../utils/helpers'; + +sentryTest( + 'does not send profile envelope when document-policy is not set', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + // Assert that no profile_chunk envelope is sent without policy header + const chunkCount = await countEnvelopes(page, { url, envelopeType: 'profile_chunk', timeout: 1500 }); + expect(chunkCount).toBe(0); + }, +); + +sentryTest( + 'sends profile_chunk envelopes in trace mode (multiple chunks)', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + + // Expect at least 2 chunks because subject creates two separate root spans, + // causing the profiler to stop and emit a chunk after each root span ends. + const profileChunkEnvelopes = await getMultipleSentryEnvelopeRequests( + page, + 2, + { url, envelopeType: 'profile_chunk', timeout: 5000 }, + properFullEnvelopeRequestParser, + ); + + expect(profileChunkEnvelopes.length).toBeGreaterThanOrEqual(2); + + // Validate the first chunk thoroughly + const profileChunkEnvelopeItem = profileChunkEnvelopes[0][1][0]; + const envelopeItemHeader = profileChunkEnvelopeItem[0]; + const envelopeItemPayload1 = profileChunkEnvelopeItem[1]; + + expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); + + expect(envelopeItemPayload1.profile).toBeDefined(); + expect(envelopeItemPayload1.version).toBe('2'); + expect(envelopeItemPayload1.platform).toBe('javascript'); + + // Required profile metadata (Sample Format V2) + expect(typeof envelopeItemPayload1.profiler_id).toBe('string'); + expect(envelopeItemPayload1.profiler_id).toMatch(/^[a-f0-9]{32}$/); + expect(typeof envelopeItemPayload1.chunk_id).toBe('string'); + expect(envelopeItemPayload1.chunk_id).toMatch(/^[a-f0-9]{32}$/); + expect(envelopeItemPayload1.client_sdk).toBeDefined(); + expect(typeof envelopeItemPayload1.client_sdk.name).toBe('string'); + expect(typeof envelopeItemPayload1.client_sdk.version).toBe('string'); + expect(typeof envelopeItemPayload1.release).toBe('string'); + expect(envelopeItemPayload1.debug_meta).toBeDefined(); + expect(Array.isArray(envelopeItemPayload1?.debug_meta?.images)).toBe(true); + + const profile1 = envelopeItemPayload1.profile; + + expect(profile1.samples).toBeDefined(); + expect(profile1.stacks).toBeDefined(); + expect(profile1.frames).toBeDefined(); + expect(profile1.thread_metadata).toBeDefined(); + + // Samples + expect(profile1.samples.length).toBeGreaterThanOrEqual(2); + let previousTimestamp = Number.NEGATIVE_INFINITY; + for (const sample of profile1.samples) { + expect(typeof sample.stack_id).toBe('number'); + expect(sample.stack_id).toBeGreaterThanOrEqual(0); + expect(sample.stack_id).toBeLessThan(profile1.stacks.length); + + // In trace lifecycle mode, samples carry a numeric timestamp (ms since epoch or similar clock) + expect(typeof (sample as any).timestamp).toBe('number'); + const ts = (sample as any).timestamp as number; + expect(Number.isFinite(ts)).toBe(true); + expect(ts).toBeGreaterThan(0); + // Monotonic non-decreasing timestamps + expect(ts).toBeGreaterThanOrEqual(previousTimestamp); + previousTimestamp = ts; + + expect(sample.thread_id).toBe('0'); // Should be main thread + } + + // Stacks + expect(profile1.stacks.length).toBeGreaterThan(0); + for (const stack of profile1.stacks) { + expect(Array.isArray(stack)).toBe(true); + for (const frameIndex of stack) { + expect(typeof frameIndex).toBe('number'); + expect(frameIndex).toBeGreaterThanOrEqual(0); + expect(frameIndex).toBeLessThan(profile1.frames.length); + } + } + + // Frames + expect(profile1.frames.length).toBeGreaterThan(0); + for (const frame of profile1.frames) { + expect(frame).toHaveProperty('function'); + expect(typeof frame.function).toBe('string'); + + if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { + expect(frame).toHaveProperty('abs_path'); + expect(frame).toHaveProperty('lineno'); + expect(frame).toHaveProperty('colno'); + expect(typeof frame.abs_path).toBe('string'); + expect(typeof frame.lineno).toBe('number'); + expect(typeof frame.colno).toBe('number'); + } + } + + const functionNames = profile1.frames.map(frame => frame.function).filter(name => name !== ''); + + if ((process.env.PW_BUNDLE || '').endsWith('min')) { + // In bundled mode, function names are minified + expect(functionNames.length).toBeGreaterThan(0); + expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings + } else { + expect(functionNames).toEqual( + expect.arrayContaining([ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + + // first function is captured (other one is in other chunk) + 'fibonacci', + ]), + ); + } + + expect(profile1.thread_metadata).toHaveProperty('0'); + expect(profile1.thread_metadata['0']).toHaveProperty('name'); + expect(profile1.thread_metadata['0'].name).toBe('main'); + + // Test that profile duration makes sense (should be > 20ms based on test setup) + const startTimeSec = (profile1.samples[0] as any).timestamp as number; + const endTimeSec = (profile1.samples[profile1.samples.length - 1] as any).timestamp as number; + const durationSec = endTimeSec - startTimeSec; + + // Should be at least 20ms based on our setTimeout(21) in the test + expect(durationSec).toBeGreaterThan(0.2); + + // === PROFILE CHUNK 2 === + + const profileChunkEnvelopeItem2 = profileChunkEnvelopes[1][1][0]; + const envelopeItemHeader2 = profileChunkEnvelopeItem2[0]; + const envelopeItemPayload2 = profileChunkEnvelopeItem2[1]; + + // Basic sanity on the second chunk: has correct envelope type and structure + expect(envelopeItemHeader2).toHaveProperty('type', 'profile_chunk'); + expect(envelopeItemPayload2.profile).toBeDefined(); + expect(envelopeItemPayload2.version).toBe('2'); + expect(envelopeItemPayload2.platform).toBe('javascript'); + + // Required profile metadata (Sample Format V2) + // https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ + expect(typeof envelopeItemPayload2.profiler_id).toBe('string'); + expect(envelopeItemPayload2.profiler_id).toMatch(/^[a-f0-9]{32}$/); + expect(typeof envelopeItemPayload2.chunk_id).toBe('string'); + expect(envelopeItemPayload2.chunk_id).toMatch(/^[a-f0-9]{32}$/); + expect(envelopeItemPayload2.client_sdk).toBeDefined(); + expect(typeof envelopeItemPayload2.client_sdk.name).toBe('string'); + expect(typeof envelopeItemPayload2.client_sdk.version).toBe('string'); + expect(typeof envelopeItemPayload2.release).toBe('string'); + expect(envelopeItemPayload2.debug_meta).toBeDefined(); + expect(Array.isArray(envelopeItemPayload2?.debug_meta?.images)).toBe(true); + + const profile2 = envelopeItemPayload2.profile; + + const functionNames2 = profile2.frames.map(frame => frame.function).filter(name => name !== ''); + + if ((process.env.PW_BUNDLE || '').endsWith('min')) { + // In bundled mode, function names are minified + expect(functionNames2.length).toBeGreaterThan(0); + expect((functionNames2 as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings + } else { + expect(functionNames2).toEqual( + expect.arrayContaining([ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + + // second function is captured (other one is in other chunk) + 'largeSum', + ]), + ); + } + }, +); 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 new file mode 100644 index 000000000000..071afe1ed059 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js @@ -0,0 +1,52 @@ +import * as Sentry from '@sentry/browser'; +import { browserProfilingIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [browserProfilingIntegration()], + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', +}); + +function largeSum(amount = 1000000) { + let sum = 0; + for (let i = 0; i < amount; i++) { + sum += Math.sqrt(i) * Math.sin(i); + } +} + +function fibonacci(n) { + if (n <= 1) { + return n; + } + return fibonacci(n - 1) + fibonacci(n - 2); +} + +let firstSpan; + +Sentry.startSpanManual({ name: 'root-largeSum-1', parentSpan: null, forceTransaction: true }, span => { + largeSum(); + firstSpan = span; +}); + +await Sentry.startSpanManual({ name: 'root-fibonacci-2', parentSpan: null, forceTransaction: true }, async span => { + fibonacci(40); + + Sentry.startSpan({ name: 'child-fibonacci', parentSpan: span }, childSpan => { + console.log('child span'); + }); + + // Timeout to prevent flaky tests. Integration samples every 20ms, if function is too fast it might not get sampled + await new Promise(resolve => setTimeout(resolve, 21)); + span.end(); +}); + +await new Promise(r => setTimeout(r, 21)); + +firstSpan.end(); + +const client = Sentry.getClient(); +await client?.flush(5000); 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..60744def96cd --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts @@ -0,0 +1,187 @@ +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, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // 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'); + + // 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(); + 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.timestamp).toBe('number'); + const ts = sample.timestamp; + 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(typeof frame.function).toBe('string'); + + if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { + expect(frame).toHaveProperty('abs_path'); + expect(frame).toHaveProperty('lineno'); + expect(frame).toHaveProperty('colno'); + expect(typeof frame.abs_path).toBe('string'); + expect(typeof frame.lineno).toBe('number'); + expect(typeof frame.colno).toBe('number'); + } + } + + const functionNames = 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 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(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 7ad77d8920e5..415282698d45 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -1,15 +1,21 @@ 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 './lifecycleMode/traceLifecycleProfiler'; import { startProfileForSpan } from './startProfileForSpan'; import type { ProfiledEvent } from './utils'; import { addProfilesToEnvelope, + attachProfiledThreadToEvent, createProfilingEvent, findProfiledTransactionsFromEnvelope, getActiveProfilesCount, + hasLegacyProfiling, isAutomatedPageLoadSpan, - shouldProfileSpan, + shouldProfileSession, + shouldProfileSpanLegacy, takeProfileFromGlobalCache, } from './utils'; @@ -19,73 +25,133 @@ const _browserProfilingIntegration = (() => { return { name: INTEGRATION_NAME, setup(client) { + const options = client.getOptions() as BrowserOptions; + + if (!hasLegacyProfiling(options) && !options.profileLifecycle) { + // Set default lifecycle mode + options.profileLifecycle = 'manual'; + } + + if (hasLegacyProfiling(options) && !options.profilesSampleRate) { + DEBUG_BUILD && debug.log('[Profiling] Profiling disabled, no profiling options found.'); + return; + } + const activeSpan = getActiveSpan(); const rootSpan = activeSpan && getRootSpan(activeSpan); - if (rootSpan && isAutomatedPageLoadSpan(rootSpan)) { - if (shouldProfileSpan(rootSpan)) { - startProfileForSpan(rootSpan); - } + 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.', + ); } - client.on('spanStart', (span: Span) => { - if (span === getRootSpan(span) && shouldProfileSpan(span)) { - startProfileForSpan(span); + // UI PROFILING (Profiling V2) + if (!hasLegacyProfiling(options)) { + const sessionSampled = shouldProfileSession(options); + if (!sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled. Skipping lifecycle profiler initialization.'); } - }); - client.on('beforeEnvelope', (envelope): void => { - // if not profiles are in queue, there is nothing to add to the envelope. - if (!getActiveProfilesCount()) { - return; - } + const lifecycleMode = options.profileLifecycle; - const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); - if (!profiledTransactionEvents.length) { - return; - } + 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; + } - const profilesToAddToEnvelope: Profile[] = []; + 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); + }); + } + }, + processEvent(event) { + return attachProfiledThreadToEvent(event); }, }; }) satisfies IntegrationFn; diff --git a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts new file mode 100644 index 000000000000..3ce773fe01ff --- /dev/null +++ b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts @@ -0,0 +1,355 @@ +import type { Client, ProfileChunk, Span } from '@sentry/core'; +import { + type ProfileChunkEnvelope, + createEnvelope, + debug, + dsnToString, + getGlobalScope, + getRootSpan, + getSdkMetadataForEnvelopeHeader, + uuid4, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { JSSelfProfiler } from '../jsSelfProfiling'; +import { createProfileChunkPayload, startJSSelfProfile, validateProfileChunk } from '../utils'; + +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): + * - 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; + // 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; + + public constructor() { + this._client = undefined; + this._profiler = undefined; + this._chunkTimer = undefined; + this._activeRootSpanIds = new Set(); + this._rootSpanTimeouts = new Map>(); + 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 { + // One Profiler ID per profiling session (user session) + this._profilerId = uuid4(); + + DEBUG_BUILD && debug.log("[Profiling] Initializing profiler (lifecycle='trace')."); + + this._client = client; + this._sessionSampled = sessionSampled; + + client.on('spanStart', span => { + 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; + } + + // Matching root spans with profiles + getGlobalScope().setContext('profile', { + profiler_id: this._profilerId, + }); + + const spanId = span.spanContext().spanId; + if (!spanId) { + return; + } + if (this._activeRootSpanIds.has(spanId)) { + return; + } + + 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( + `[Profiling] Root span with ID ${spanId} started. Will continue profiling for as long as there are active root spans (currently: ${rootSpanCount}).`, + ); + + this.start(); + } + }); + + client.on('spanEnd', span => { + if (!this._sessionSampled) { + return; + } + + const spanId = span.spanContext().spanId; + if (!spanId || !this._activeRootSpanIds.has(spanId)) { + return; + } + + this._activeRootSpanIds.delete(spanId); + const rootSpanCount = this._activeRootSpanIds.size; + + DEBUG_BUILD && + debug.log( + `[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(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `spanEnd`:', e); + }); + + this.stop(); + } + }); + } + + /** + * Handle an already-active root span at integration setup time. + */ + public notifyRootSpanActive(rootSpan: Span): void { + if (!this._sessionSampled) { + return; + } + + const spanId = rootSpan.spanContext().spanId; + if (!spanId || this._activeRootSpanIds.has(spanId)) { + return; + } + + this._activeRootSpanIds.add(spanId); + + 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(); + } + } + + /** + * Start profiling if not already running. + */ + public start(): void { + if (this._isRunning) { + return; + } + this._isRunning = true; + + 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._resetProfilerInfo(); + return; + } + + this._startPeriodicChunking(); + } + + /** + * 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._clearAllRootSpanTimeouts(); + + // Collect whatever was currently recording + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `stop()`:', e); + }); + } + + /** + * Resets profiling information from scope and resets running state + */ + private _resetProfilerInfo(): void { + this._isRunning = false; + 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. + */ + private _startProfilerInstance(): void { + if (this._profiler?.stopped === false) { + return; + } + const profiler = startJSSelfProfile(); + if (!profiler) { + DEBUG_BUILD && debug.log('[Profiling] Failed to start JS 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 _startPeriodicChunking(): void { + if (!this._isRunning) { + return; + } + + this._chunkTimer = setTimeout(() => { + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk during periodic chunking:', e); + }); + + if (this._isRunning) { + this._startProfilerInstance(); + + if (!this._profiler) { + // If restart failed, stop scheduling further chunks and reset context. + this._resetProfilerInfo(); + return; + } + + 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. + */ + private async _collectCurrentChunk(): Promise { + const prevProfiler = this._profiler; + this._profiler = undefined; + + if (!prevProfiler) { + return; + } + + try { + const profile = await prevProfiler.stop(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const chunk = createProfileChunkPayload(profile, this._client!, this._profilerId); + + // Validate chunk before sending + 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):', + validationReturn.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 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 dsn = client.getDsn(); + const tunnel = client.getOptions().tunnel; + + const envelope = createEnvelope( + { + event_id: uuid4(), + sent_at: new Date().toISOString(), + ...(sdkInfo && { sdk: sdkInfo }), + ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), + }, + [[{ type: 'profile_chunk' }, chunk]], + ); + + client.sendEnvelope(envelope).then(null, reason => { + DEBUG_BUILD && debug.error('Error while sending profile chunk envelope:', reason); + }); + } +} 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 8b7039be7a9b..ed794a40a98b 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, @@ -7,19 +18,24 @@ import { forEachEnvelopeItem, getClient, getDebugImagesForResources, + GLOBAL_OBJ, spanToJSON, 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'; 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'; + +// 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... const navigator = WINDOW.navigator as typeof WINDOW.navigator | undefined; @@ -185,7 +201,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), }, @@ -195,6 +211,161 @@ export function createProfilePayload( return profile; } +/** + * Create a profile chunk envelope item + */ +export function createProfileChunkPayload( + jsSelfProfile: JSSelfProfile, + client: Client, + profilerId?: string, +): ProfileChunk { + // only == to catch null and undefined + if (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: { + // function name obfuscation + images: applyDebugMetadata(jsSelfProfile.resources), + }, + profile: continuousProfile, + }; +} + +/** + * 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: true } | { reason: string } { + try { + // Required metadata + if (!chunk || typeof chunk !== '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 { reason: 'missing or invalid profiler_id' }; + } + if (!isHex32(chunk.chunk_id)) { + return { reason: 'missing or invalid chunk_id' }; + } + + if (!chunk.client_sdk) { + 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 { reason: 'missing profile data' }; + } + + if (!Array.isArray(profile.frames) || !profile.frames.length) { + return { reason: 'profile has no frames' }; + } + if (!Array.isArray(profile.stacks) || !profile.stacks.length) { + return { reason: 'profile has no stacks' }; + } + if (!Array.isArray(profile.samples) || !profile.samples.length) { + return { reason: 'profile has no samples' }; + } + + return { valid: true }; + } catch (e) { + return { reason: `unknown validation error: ${e}` }; + } +} + +/** + * 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: PROFILER_THREAD_ID_STRING, + timestamp: timestampSeconds, + }; + } + + return { + frames, + stacks, + samples, + thread_metadata: { [PROFILER_THREAD_ID_STRING]: { name: PROFILER_THREAD_NAME } }, + }; +} + /** * */ @@ -226,7 +397,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 }, }, }; @@ -258,7 +429,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; } @@ -291,7 +462,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; @@ -459,7 +630,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 +640,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 +687,46 @@ 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.profileLifecycle !== 'trace') { + return false; + } + + // Session sampling: profileSessionSampleRate gates whether profiling is enabled for this session + const profileSessionSampleRate = options.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 Math.random() <= profileSessionSampleRate; +} + +/** + * Checks if legacy profiling is configured. + */ +export function hasLegacyProfiling(options: BrowserOptions): boolean { + return typeof options.profilesSampleRate !== 'undefined'; +} + /** * Creates a profiling envelope item, if the profile does not pass validation, returns null. * @param event @@ -564,8 +773,44 @@ 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!; - PROFILE_MAP.delete(last); + const last = PROFILE_MAP.keys().next().value; + if (last !== undefined) { + PROFILE_MAP.delete(last); + } } } + +/** + * Attaches the profiled thread information to the event's trace context. + */ +export function attachProfiledThreadToEvent(event: Event): Event { + if (!event?.contexts?.profile) { + return event; + } + + if (!event.contexts) { + return event; + } + + // @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, + }, + }; + + // Attach thread info to individual spans so that spans can be associated with the profiled thread on the UI even if contexts are missing. + event.spans?.forEach(span => { + span.data = { + ...(span.data || {}), + ['thread.id']: PROFILER_THREAD_ID_STRING, + ['thread.name']: PROFILER_THREAD_NAME, + }; + }); + + return event; +} diff --git a/packages/browser/test/profiling/integration.test.ts b/packages/browser/test/profiling/integration.test.ts index 2af3cb662689..f9d97230701c 100644 --- a/packages/browser/test/profiling/integration.test.ts +++ b/packages/browser/test/profiling/integration.test.ts @@ -3,7 +3,9 @@ */ 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', () => { @@ -65,4 +67,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(debug, '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 'manual' 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()?.profileLifecycle; + expect(lifecycle).toBe('manual'); + }); }); diff --git a/packages/browser/test/profiling/traceLifecycleProfiler.test.ts b/packages/browser/test/profiling/traceLifecycleProfiler.test.ts new file mode 100644 index 000000000000..f28880960256 --- /dev/null +++ b/packages/browser/test/profiling/traceLifecycleProfiler.test.ts @@ -0,0 +1,631 @@ +/** + * @vitest-environment jsdom + */ + +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', () => { + 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(); + }); + + 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(); + }); + + 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); + }); + }); + + 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: [], + }); + + 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), + }, + }, + }); + }); + + 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() {} + } + + (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-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); + }); + + 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); + } + }); + + it('changes profiler_id when a new user session starts (new SDK init)', 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)); + + // 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); + } + }); + }); +}); diff --git a/packages/core/src/types-hoist/browseroptions.ts b/packages/core/src/types-hoist/browseroptions.ts index 1df40c6fd614..18bbd46af09c 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, 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; + + /** + * 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' */