diff --git a/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js b/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js new file mode 100644 index 000000000000..906f14d06693 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js @@ -0,0 +1,76 @@ +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: 'manual', +}); + +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); +} + +function fibonacci1(n) { + if (n <= 1) { + return n; + } + return fibonacci1(n - 1) + fibonacci1(n - 2); +} + +function fibonacci2(n) { + if (n <= 1) { + return n; + } + return fibonacci1(n - 1) + fibonacci1(n - 2); +} + +function notProfiledFib(n) { + if (n <= 1) { + return n; + } + return fibonacci1(n - 1) + fibonacci1(n - 2); +} + +// Adding setTimeout to ensure we cross the sampling interval to avoid flakes + +Sentry.uiProfiler.startProfiler(); + +fibonacci(40); +await new Promise(resolve => setTimeout(resolve, 25)); + +largeSum(); +await new Promise(resolve => setTimeout(resolve, 25)); + +Sentry.uiProfiler.stopProfiler(); + +// --- + +notProfiledFib(40); +await new Promise(resolve => setTimeout(resolve, 25)); + +// --- + +Sentry.uiProfiler.startProfiler(); + +fibonacci2(40); +await new Promise(resolve => setTimeout(resolve, 25)); + +Sentry.uiProfiler.stopProfiler(); + +const client = Sentry.getClient(); +await client?.flush(8000); diff --git a/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts new file mode 100644 index 000000000000..2e4358563aa2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts @@ -0,0 +1,93 @@ +import { expect } from '@playwright/test'; +import type { ProfileChunkEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + countEnvelopes, + getMultipleSentryEnvelopeRequests, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../utils/helpers'; +import { validateProfile, validateProfilePayloadMetadata } from '../test-utils'; + +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 manual 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' } }); + + // In manual mode we start and stop once -> expect exactly one chunk + const profileChunkEnvelopes = await getMultipleSentryEnvelopeRequests( + page, + 2, + { url, envelopeType: 'profile_chunk', timeout: 8000 }, + properFullEnvelopeRequestParser, + ); + + expect(profileChunkEnvelopes.length).toBe(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(); + + const profilerId1 = envelopeItemPayload1.profiler_id; + + validateProfilePayloadMetadata(envelopeItemPayload1); + + validateProfile(envelopeItemPayload1.profile, { + expectedFunctionNames: ['startJSSelfProfile', 'fibonacci', 'largeSum'], + minSampleDurationMs: 20, + isChunkFormat: true, + }); + + // only contains fibonacci + const functionNames1 = envelopeItemPayload1.profile.frames.map(frame => frame.function).filter(name => name !== ''); + expect(functionNames1).toEqual(expect.not.arrayContaining(['fibonacci1', 'fibonacci2', 'fibonacci3'])); + + // === PROFILE CHUNK 2 === + + const profileChunkEnvelopeItem2 = profileChunkEnvelopes[1][1][0]; + const envelopeItemHeader2 = profileChunkEnvelopeItem2[0]; + const envelopeItemPayload2 = profileChunkEnvelopeItem2[1]; + + expect(envelopeItemHeader2).toHaveProperty('type', 'profile_chunk'); + expect(envelopeItemPayload2.profile).toBeDefined(); + + expect(envelopeItemPayload2.profiler_id).toBe(profilerId1); // same profiler id for the whole session + + validateProfilePayloadMetadata(envelopeItemPayload2); + + validateProfile(envelopeItemPayload2.profile, { + expectedFunctionNames: [ + 'startJSSelfProfile', + 'fibonacci1', // called by fibonacci2 + 'fibonacci2', + ], + isChunkFormat: true, + }); + + // does not contain notProfiledFib (called during unprofiled part) + const functionNames2 = envelopeItemPayload2.profile.frames.map(frame => frame.function).filter(name => name !== ''); + expect(functionNames2).toEqual(expect.not.arrayContaining(['notProfiledFib'])); +}); diff --git a/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts b/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts index e150be2d56bc..39e6d2ca20b7 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts @@ -90,7 +90,7 @@ export function validateProfile( } } - // Frames + // FRAMES expect(profile.frames.length).toBeGreaterThan(0); for (const frame of profile.frames) { expect(frame).toHaveProperty('function'); diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index ea55174f340c..65fcdf24734a 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -130,7 +130,6 @@ export class BrowserClient extends Client { // Flush logs and metrics when page becomes hidden (e.g., tab switch, navigation) // todo(v11): Remove the experimental flag - // eslint-disable-next-line deprecation/deprecation if (WINDOW.document && (sendClientReports || enableLogs || enableMetrics)) { WINDOW.document.addEventListener('visibilitychange', () => { if (WINDOW.document.visibilityState === 'hidden') { diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index 50223e4b9fd9..1b46687194da 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -78,6 +78,7 @@ export { export { WINDOW } from './helpers'; export { BrowserClient } from './client'; export { makeFetchTransport } from './transports/fetch'; +export { uiProfiler } from './profiling'; export { defaultStackParser, defaultStackLineParsers, diff --git a/packages/browser/src/profiling/UIProfiler.ts b/packages/browser/src/profiling/UIProfiler.ts index 731684996d62..fb059b836986 100644 --- a/packages/browser/src/profiling/UIProfiler.ts +++ b/packages/browser/src/profiling/UIProfiler.ts @@ -1,4 +1,4 @@ -import type { Client, ProfileChunk, Span } from '@sentry/core'; +import type { Client, ContinuousProfiler, ProfileChunk, Span } from '@sentry/core'; import { type ProfileChunkEnvelope, createEnvelope, @@ -9,67 +9,122 @@ import { getSdkMetadataForEnvelopeHeader, uuid4, } from '@sentry/core'; +import type { BrowserOptions } from '../client'; import { DEBUG_BUILD } from './../debug-build'; import type { JSSelfProfiler } from './jsSelfProfiling'; -import { createProfileChunkPayload, startJSSelfProfile, validateProfileChunk } from './utils'; +import { createProfileChunkPayload, shouldProfileSession, 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 +const MAX_ROOT_SPAN_PROFILE_MS = 300_000; // 5 minutes max per root span in trace mode /** - * 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 + * UIProfiler (Profiling V2): + * Supports two lifecycle modes: + * - 'manual': controlled explicitly via start()/stop() + * - 'trace': automatically runs while there are active sampled root spans * * 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 UIProfiler { +export class UIProfiler implements ContinuousProfiler { private _client: Client | undefined; private _profiler: JSSelfProfiler | undefined; private _chunkTimer: ReturnType | undefined; - // For keeping track of active root spans + + // Manual + Trace + private _profilerId: string | undefined; // one per Profiler session + private _isRunning: boolean; // current profiler instance active flag + private _sessionSampled: boolean; // sampling decision for entire session + private _lifecycleMode: 'manual' | 'trace' | undefined; + + // Trace-only 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; + this._lifecycleMode = undefined; + + this._activeRootSpanIds = new Set(); + this._rootSpanTimeouts = new Map(); } /** - * Initialize the profiler with client and session sampling decision computed by the integration. + * Initialize the profiler with client, session sampling and lifecycle mode. */ - public initialize(client: Client, sessionSampled: boolean): void { - // One Profiler ID per profiling session (user session) - this._profilerId = uuid4(); + public initialize(client: Client): void { + const lifecycleMode = (client.getOptions() as BrowserOptions).profileLifecycle; + const sessionSampled = shouldProfileSession(client.getOptions()); + + DEBUG_BUILD && debug.log(`[Profiling] Initializing profiler (lifecycle='${lifecycleMode}').`); - DEBUG_BUILD && debug.log("[Profiling] Initializing profiler (lifecycle='trace')."); + if (!sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled. Skipping lifecycle profiler initialization.'); + } + // One Profiler ID per profiling session (user session) + this._profilerId = uuid4(); this._client = client; this._sessionSampled = sessionSampled; + this._lifecycleMode = lifecycleMode; - this._setupTraceLifecycleListeners(client); + if (lifecycleMode === 'trace') { + this._setupTraceLifecycleListeners(client); + } } - /** - * Handle an already-active root span at integration setup time. - */ - public notifyRootSpanActive(rootSpan: Span): void { + /** Starts UI profiling (only effective in 'manual' mode and when sampled). */ + public start(): void { + if (this._lifecycleMode === 'trace') { + DEBUG_BUILD && + debug.warn( + '[Profiling] `profileLifecycle` is set to "trace". Calls to `uiProfiler.start()` are ignored in trace mode.', + ); + return; + } + + if (this._isRunning) { + DEBUG_BUILD && debug.warn('[Profiling] Profile session is already running, `uiProfiler.start()` is a no-op.'); + return; + } + if (!this._sessionSampled) { + DEBUG_BUILD && debug.warn('[Profiling] Session is not sampled, `uiProfiler.start()` is a no-op.'); + return; + } + + this._beginProfiling(); + } + + /** Stops UI profiling (only effective in 'manual' mode). */ + public stop(): void { + if (this._lifecycleMode === 'trace') { + DEBUG_BUILD && + debug.warn( + '[Profiling] `profileLifecycle` is set to "trace". Calls to `uiProfiler.stop()` are ignored in trace mode.', + ); + return; + } + + if (!this._isRunning) { + DEBUG_BUILD && debug.warn('[Profiling] Profiler is not running, `uiProfiler.stop()` is a no-op.'); + return; + } + + this._endProfiling(); + } + + /** Handle an already-active root span at integration setup time (used only in trace mode). */ + public notifyRootSpanActive(rootSpan: Span): void { + if (this._lifecycleMode !== 'trace' || !this._sessionSampled) { return; } @@ -78,7 +133,7 @@ export class UIProfiler { return; } - this._activeRootSpanIds.add(spanId); + this._registerTraceRootSpan(spanId); const rootSpanCount = this._activeRootSpanIds.size; @@ -86,20 +141,20 @@ export class UIProfiler { DEBUG_BUILD && debug.log('[Profiling] Detected already active root span during setup. Active root spans now:', rootSpanCount); - this.start(); + this._beginProfiling(); } } /** - * Start profiling if not already running. + * Begin profiling if not already running. */ - public start(): void { + private _beginProfiling(): void { if (this._isRunning) { return; } this._isRunning = true; - DEBUG_BUILD && debug.log('[Profiling] Started profiling with profile ID:', this._profilerId); + DEBUG_BUILD && debug.log('[Profiling] Started profiling with profiler ID:', this._profilerId); // Expose profiler_id to match root spans with profiles getGlobalScope().setContext('profile', { profiler_id: this._profilerId }); @@ -107,7 +162,7 @@ export class UIProfiler { this._startProfilerInstance(); if (!this._profiler) { - DEBUG_BUILD && debug.log('[Profiling] Stopping trace lifecycle profiling.'); + DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler; stopping.'); this._resetProfilerInfo(); return; } @@ -115,15 +170,13 @@ export class UIProfiler { this._startPeriodicChunking(); } - /** - * Stop profiling; final chunk will be collected and sent. - */ - public stop(): void { + /** End profiling session; final chunk will be collected and sent. */ + private _endProfiling(): void { if (!this._isRunning) { return; } - this._isRunning = false; + if (this._chunkTimer) { clearTimeout(this._chunkTimer); this._chunkTimer = undefined; @@ -135,6 +188,12 @@ export class UIProfiler { this._collectCurrentChunk().catch(e => { DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `stop()`:', e); }); + + // Manual: Clear profiling context so spans outside start()/stop() aren't marked as profiled + // Trace: Profile context is kept for the whole session duration + if (this._lifecycleMode === 'manual') { + getGlobalScope().setContext('profile', {}); + } } /** Trace-mode: attach spanStart/spanEnd listeners. */ @@ -166,7 +225,7 @@ export class UIProfiler { debug.log( `[Profiling] Root span ${spanId} started. Profiling active while there are active root spans (count=${rootSpanCount}).`, ); - this.start(); + this._beginProfiling(); } }); @@ -189,13 +248,13 @@ export class UIProfiler { this._collectCurrentChunk().catch(e => { DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on last `spanEnd`:', e); }); - this.stop(); + this._endProfiling(); } }); } /** - * Resets profiling information from scope and resets running state + * Resets profiling information from scope and resets running state (used on failure) */ private _resetProfilerInfo(): void { this._isRunning = false; @@ -210,7 +269,7 @@ export class UIProfiler { this._rootSpanTimeouts.clear(); } - /** Register root span and schedule safeguard timeout (trace mode). */ + /** Keep track of root spans and schedule safeguard timeout (trace mode). */ private _registerTraceRootSpan(spanId: string): void { this._activeRootSpanIds.add(spanId); const timeout = setTimeout(() => this._onRootSpanTimeout(spanId), MAX_ROOT_SPAN_PROFILE_MS); @@ -222,11 +281,11 @@ export class UIProfiler { */ private _startProfilerInstance(): void { if (this._profiler?.stopped === false) { - return; + return; // already running } const profiler = startJSSelfProfile(); if (!profiler) { - DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler in trace lifecycle.'); + DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler.'); return; } this._profiler = profiler; @@ -283,14 +342,13 @@ export class UIProfiler { this._activeRootSpanIds.delete(rootSpanId); - const rootSpanCount = this._activeRootSpanIds.size; - if (rootSpanCount === 0) { - this.stop(); + if (this._activeRootSpanIds.size === 0) { + this._endProfiling(); } } /** - * Stop the current profiler, convert and send a profile chunk. + * Stop current profiler instance, convert profile to chunk & send. */ private async _collectCurrentChunk(): Promise { const prevProfiler = this._profiler; diff --git a/packages/browser/src/profiling/index.ts b/packages/browser/src/profiling/index.ts new file mode 100644 index 000000000000..32d4c94fe55c --- /dev/null +++ b/packages/browser/src/profiling/index.ts @@ -0,0 +1,76 @@ +import type { Profiler, ProfilingIntegration } from '@sentry/core'; +import { debug, getClient } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +function isProfilingIntegrationWithProfiler( + integration: ProfilingIntegration | undefined, +): integration is ProfilingIntegration { + return ( + !!integration && + typeof integration['_profiler'] !== 'undefined' && + typeof integration['_profiler']['start'] === 'function' && + typeof integration['_profiler']['stop'] === 'function' + ); +} + +/** + * Starts the Sentry UI profiler. + * This mode is exclusive with the transaction profiler and will only work if the profilesSampleRate is set to a falsy value. + * In UI profiling mode, the profiler will keep reporting profile chunks to Sentry until it is stopped, which allows for continuous profiling of the application. + */ +function startProfiler(): void { + const client = getClient(); + if (!client) { + DEBUG_BUILD && debug.warn('No Sentry client available, profiling is not started'); + return; + } + + const integration = client.getIntegrationByName('BrowserProfiling'); + + if (!integration) { + DEBUG_BUILD && debug.warn('BrowserProfiling integration is not available'); + return; + } + + if (!isProfilingIntegrationWithProfiler(integration)) { + DEBUG_BUILD && debug.warn('Profiler is not available on profiling integration.'); + return; + } + + integration._profiler.start(); +} + +/** + * Stops the Sentry UI profiler. + * Calls to stop will stop the profiler and flush the currently collected profile data to Sentry. + */ +function stopProfiler(): void { + const client = getClient(); + if (!client) { + DEBUG_BUILD && debug.warn('No Sentry client available, profiling is not started'); + return; + } + + const integration = client.getIntegrationByName('BrowserProfiling'); + if (!integration) { + DEBUG_BUILD && debug.warn('ProfilingIntegration is not available'); + return; + } + + if (!isProfilingIntegrationWithProfiler(integration)) { + DEBUG_BUILD && debug.warn('Profiler is not available on profiling integration.'); + return; + } + + integration._profiler.stop(); +} + +/** + * Profiler namespace for controlling the JS profiler in 'manual' mode. + * + * Requires the `browserProfilingIntegration` from the `@sentry/browser` package. + */ +export const uiProfiler: Profiler = { + startProfiler, + stopProfiler, +}; diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 7cd1886e636d..08dd5b8a08dc 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -1,4 +1,4 @@ -import type { EventEnvelope, IntegrationFn, Profile, Span } from '@sentry/core'; +import type { EventEnvelope, Integration, IntegrationFn, Profile, Span } from '@sentry/core'; import { debug, defineIntegration, getActiveSpan, getRootSpan, hasSpansEnabled } from '@sentry/core'; import type { BrowserOptions } from '../client'; import { DEBUG_BUILD } from '../debug-build'; @@ -14,7 +14,6 @@ import { getActiveProfilesCount, hasLegacyProfiling, isAutomatedPageLoadSpan, - shouldProfileSession, shouldProfileSpanLegacy, takeProfileFromGlobalCache, } from './utils'; @@ -24,6 +23,7 @@ const INTEGRATION_NAME = 'BrowserProfiling'; const _browserProfilingIntegration = (() => { return { name: INTEGRATION_NAME, + _profiler: new UIProfiler(), setup(client) { const options = client.getOptions() as BrowserOptions; @@ -32,6 +32,7 @@ const _browserProfilingIntegration = (() => { options.profileLifecycle = 'manual'; } + // eslint-disable-next-line deprecation/deprecation if (hasLegacyProfiling(options) && !options.profilesSampleRate) { DEBUG_BUILD && debug.log('[Profiling] Profiling disabled, no profiling options found.'); return; @@ -49,14 +50,11 @@ const _browserProfilingIntegration = (() => { // 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.'); - } - const lifecycleMode = options.profileLifecycle; - if (lifecycleMode === 'trace') { + if (lifecycleMode === 'manual') { + this._profiler.initialize(client); + } else if (lifecycleMode === 'trace') { if (!hasSpansEnabled(options)) { DEBUG_BUILD && debug.warn( @@ -65,12 +63,11 @@ const _browserProfilingIntegration = (() => { return; } - const traceLifecycleProfiler = new UIProfiler(); - traceLifecycleProfiler.initialize(client, sessionSampled); + this._profiler.initialize(client); // If there is an active, sampled root span already, notify the profiler if (rootSpan) { - traceLifecycleProfiler.notifyRootSpanActive(rootSpan); + this._profiler.notifyRootSpanActive(rootSpan); } // In case rootSpan is created slightly after setup -> schedule microtask to re-check and notify. @@ -78,7 +75,7 @@ const _browserProfilingIntegration = (() => { const laterActiveSpan = getActiveSpan(); const laterRootSpan = laterActiveSpan && getRootSpan(laterActiveSpan); if (laterRootSpan) { - traceLifecycleProfiler.notifyRootSpanActive(laterRootSpan); + this._profiler.notifyRootSpanActive(laterRootSpan); } }, 0); } @@ -154,6 +151,10 @@ const _browserProfilingIntegration = (() => { return attachProfiledThreadToEvent(event); }, }; -}) satisfies IntegrationFn; +}) satisfies IntegrationFn; + +interface BrowserProfilingIntegration extends Integration { + _profiler: UIProfiler; +} export const browserProfilingIntegration = defineIntegration(_browserProfilingIntegration); diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index ed794a40a98b..c50c76c84de4 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -651,8 +651,10 @@ export function shouldProfileSpanLegacy(span: Span): boolean { return false; } - // @ts-expect-error profilesSampleRate is not part of the browser options yet - const profilesSampleRate: number | boolean | undefined = options.profilesSampleRate; + // eslint-disable-next-line deprecation/deprecation + const profilesSampleRate = (options as BrowserOptions).profilesSampleRate as + | BrowserOptions['profilesSampleRate'] + | boolean; // Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The // only valid values are booleans or numbers between 0 and 1.) @@ -688,18 +690,21 @@ export function shouldProfileSpanLegacy(span: Span): boolean { } /** - * Determine if a profile should be created for the current session (lifecycle profiling mode). + * Determine if a profile should be created for the current session. */ 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.'); + debug.log( + '[Profiling] Profiling has been disabled for the duration of the current user session as the JS Profiler could not be started.', + ); } return false; } - if (options.profileLifecycle !== 'trace') { + if (options.profileLifecycle !== 'trace' && options.profileLifecycle !== 'manual') { + DEBUG_BUILD && debug.warn('[Profiling] Session not sampled. Invalid `profileLifecycle` option.'); return false; } @@ -724,6 +729,7 @@ export function shouldProfileSession(options: BrowserOptions): boolean { * Checks if legacy profiling is configured. */ export function hasLegacyProfiling(options: BrowserOptions): boolean { + // eslint-disable-next-line deprecation/deprecation return typeof options.profilesSampleRate !== 'undefined'; } diff --git a/packages/browser/test/profiling/UIProfiler.test.ts b/packages/browser/test/profiling/UIProfiler.test.ts index f28880960256..6872e1e1beff 100644 --- a/packages/browser/test/profiling/UIProfiler.test.ts +++ b/packages/browser/test/profiling/UIProfiler.test.ts @@ -3,8 +3,20 @@ */ import * as Sentry from '@sentry/browser'; -import type { Span } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type Span, debug } from '@sentry/core'; +import { type Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BrowserOptions } from '../../src/index'; + +function getBaseOptionsForTraceLifecycle(sendMock: Mock, enableTracing = true): BrowserOptions { + return { + dsn: 'https://public@o.ingest.sentry.io/1', + ...(enableTracing ? { tracesSampleRate: 1 } : {}), + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: sendMock }), + }; +} describe('Browser Profiling v2 trace lifecycle', () => { afterEach(async () => { @@ -48,12 +60,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { 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 }), + ...getBaseOptionsForTraceLifecycle(send, false), }); // warning is logged by our debug logger only when DEBUG_BUILD, so just assert no throw and no profiler @@ -79,12 +86,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { 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 }), + ...getBaseOptionsForTraceLifecycle(send), }); let spanRef: any; @@ -112,12 +114,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { 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 }), + ...getBaseOptionsForTraceLifecycle(send), }); let spanA: any; @@ -159,12 +156,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { 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 }), + ...getBaseOptionsForTraceLifecycle(send), }); let spanRef: any; @@ -195,12 +187,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { 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 }), + ...getBaseOptionsForTraceLifecycle(send), }); let spanRef: any; @@ -255,12 +242,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { 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 }), + ...getBaseOptionsForTraceLifecycle(send), }); Sentry.startSpanManual({ name: 'root-manual-never-ends', parentSpan: null, forceTransaction: true }, _span => { @@ -308,12 +290,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { 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 }), + ...getBaseOptionsForTraceLifecycle(send), }); Sentry.startSpanManual({ name: 'root-manual-never-ends', parentSpan: null, forceTransaction: true }, _span => { @@ -375,12 +352,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { 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 }), + ...getBaseOptionsForTraceLifecycle(send), }); Sentry.startSpan({ name: 'root-for-context', parentSpan: null, forceTransaction: true }, () => { @@ -440,12 +412,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { 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 }), + ...getBaseOptionsForTraceLifecycle(send), }); Sentry.startSpan({ name: 'rootSpan-1', parentSpan: null, forceTransaction: true }, () => { @@ -499,12 +466,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { 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 }), + ...getBaseOptionsForTraceLifecycle(send), }); Sentry.startSpan({ name: 'rootSpan-chunk-1', parentSpan: null, forceTransaction: true }, () => { @@ -563,12 +525,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { // 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 }), + ...getBaseOptionsForTraceLifecycle(send1), }); Sentry.startSpan({ name: 'session-1-rootSpan', parentSpan: null, forceTransaction: true }, () => { @@ -598,12 +555,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { // 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 }), + ...getBaseOptionsForTraceLifecycle(send2), }); Sentry.startSpan({ name: 'session-2-rootSpan', parentSpan: null, forceTransaction: true }, () => { @@ -628,4 +580,271 @@ describe('Browser Profiling v2 trace lifecycle', () => { } }); }); + + it('calling start and stop in trace lifecycle prints warnings', async () => { + const { stop } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + const debugWarnSpy = vi.spyOn(debug, 'warn'); + + Sentry.init({ + ...getBaseOptionsForTraceLifecycle(send), + debug: true, + }); + + Sentry.uiProfiler.startProfiler(); + Sentry.uiProfiler.startProfiler(); + + expect(debugWarnSpy).toHaveBeenCalledWith( + '[Profiling] `profileLifecycle` is set to "trace". Calls to `uiProfiler.start()` are ignored in trace mode.', + ); + + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + debugWarnSpy.mockClear(); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + expect(stop).toHaveBeenCalledTimes(0); + expect(debugWarnSpy).toHaveBeenCalledWith( + '[Profiling] `profileLifecycle` is set to "trace". Calls to `uiProfiler.stop()` are ignored in trace mode.', + ); + }); +}); + +function getBaseOptionsForManualLifecycle(sendMock: Mock, enableTracing = true): BrowserOptions { + return { + dsn: 'https://public@o.ingest.sentry.io/1', + ...(enableTracing ? { tracesSampleRate: 1 } : {}), + profileSessionSampleRate: 1, + profileLifecycle: 'manual', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: sendMock }), + }; +} + +describe('Browser Profiling v2 manual 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('starts and stops a profile session', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + }); + + const client = Sentry.getClient(); + expect(client).toBeDefined(); + + Sentry.uiProfiler.startProfiler(); + expect(mockConstructor).toHaveBeenCalledTimes(1); + + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(1); + const envelopeHeader = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + expect(envelopeHeader?.type).toBe('profile_chunk'); + }); + + it('calling start and stop while profile session is running prints warnings', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + const debugWarnSpy = vi.spyOn(debug, 'warn'); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + debug: true, + }); + + Sentry.uiProfiler.startProfiler(); + Sentry.uiProfiler.startProfiler(); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + expect(debugWarnSpy).toHaveBeenCalledWith( + '[Profiling] Profile session is already running, `uiProfiler.start()` is a no-op.', + ); + + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + debugWarnSpy.mockClear(); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(debugWarnSpy).toHaveBeenCalledWith('[Profiling] Profiler is not running, `uiProfiler.stop()` is a no-op.'); + }); + + it('profileSessionSampleRate is required', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + const debugWarnSpy = vi.spyOn(debug, 'warn'); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + profileSessionSampleRate: undefined, + }); + + Sentry.uiProfiler.startProfiler(); + expect(debugWarnSpy).toHaveBeenCalledWith( + '[Profiling] Invalid sample rate. Sample rate must be a boolean or a number between 0 and 1. Got undefined of type "undefined".', + ); + expect(debugWarnSpy).toHaveBeenCalledWith('[Profiling] Session is not sampled, `uiProfiler.start()` is a no-op.'); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + expect(mockConstructor).not.toHaveBeenCalled(); + expect(stop).not.toHaveBeenCalled(); + }); + + it('does not start profiler when profileSessionSampleRate is 0', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + profileSessionSampleRate: 0, + }); + + Sentry.uiProfiler.startProfiler(); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + expect(mockConstructor).not.toHaveBeenCalled(); + expect(stop).not.toHaveBeenCalled(); + }); + + describe('envelope', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('sends a profile_chunk envelope type', async () => { + 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({ + ...getBaseOptionsForManualLifecycle(send), + }); + + const client = Sentry.getClient(); + + Sentry.uiProfiler.startProfiler(); + await new Promise(resolve => setTimeout(resolve, 10)); + Sentry.uiProfiler.stopProfiler(); + + await client?.flush(1000); + + expect(send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]).toMatchObject({ + type: 'profile_chunk', + }); + + expect(send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[1]).toMatchObject({ + profiler_id: expect.any(String), + chunk_id: expect.any(String), + profile: expect.objectContaining({ + stacks: expect.any(Array), + }), + }); + }); + + it('reuses the same profiler_id while profiling across multiple stop/start calls', async () => { + mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + }); + + // 1. profiling cycle + Sentry.uiProfiler.startProfiler(); + Sentry.startSpan({ name: 'manual-span-1', parentSpan: null, forceTransaction: true }, () => {}); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + // Not profiled -> should not have profile context + Sentry.startSpan({ name: 'manual-span-between', parentSpan: null, forceTransaction: true }, () => {}); + + // 2. profiling cycle + Sentry.uiProfiler.startProfiler(); + Sentry.startSpan({ name: 'manual-span-2', parentSpan: null, forceTransaction: true }, () => {}); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + const client = Sentry.getClient(); + await client?.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(3); + + const firstProfilerId = transactionEvents[0]?.contexts?.profile?.profiler_id; + expect(typeof firstProfilerId).toBe('string'); + + // Middle transaction (not profiled) + expect(transactionEvents[1]?.contexts?.profile?.profiler_id).toBeUndefined(); + + const thirdProfilerId = transactionEvents[2]?.contexts?.profile?.profiler_id; + expect(typeof thirdProfilerId).toBe('string'); + expect(firstProfilerId).toBe(thirdProfilerId); // same profiler_id across session + }); + }); }); diff --git a/packages/core/src/profiling.ts b/packages/core/src/profiling.ts index 407c4a07c53c..e2e2c34e38cc 100644 --- a/packages/core/src/profiling.ts +++ b/packages/core/src/profiling.ts @@ -65,6 +65,11 @@ function stopProfiler(): void { integration._profiler.stop(); } +/** + * Profiler namespace for controlling the profiler in 'manual' mode. + * + * Requires the `nodeProfilingIntegration` from the `@sentry/profiling-node` package. + */ export const profiler: Profiler = { startProfiler, stopProfiler, diff --git a/packages/core/src/types-hoist/browseroptions.ts b/packages/core/src/types-hoist/browseroptions.ts index 18bbd46af09c..39b414d5140b 100644 --- a/packages/core/src/types-hoist/browseroptions.ts +++ b/packages/core/src/types-hoist/browseroptions.ts @@ -18,10 +18,11 @@ 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. + * + * @deprecated Use `profileSessionSampleRate` and `profileLifecycle` instead. */ profilesSampleRate?: number;