From f6c55e21c4196e9eb42c021e375f35eef51e906f Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:41:55 +0100 Subject: [PATCH 01/18] Add test for manual profiling --- .../suites/profiling/manualMode/subject.js | 80 +++++++++++++++++ .../suites/profiling/manualMode/test.ts | 89 +++++++++++++++++++ .../suites/profiling/test-utils.ts | 2 +- 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts 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..35855580cc3a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js @@ -0,0 +1,80 @@ +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.profiler.startProfiler(); + +fibonacci(40); +await new Promise(resolve => setTimeout(resolve, 25)); + +largeSum(); +await new Promise(resolve => setTimeout(resolve, 25)); + +Sentry.profiler.stopProfiler(); + +// --- + +notProfiledFib(40); +await new Promise(resolve => setTimeout(resolve, 25)); + +// --- + +Sentry.profiler.startProfiler(); + +fibonacci2(40); +await new Promise(resolve => setTimeout(resolve, 25)); + +Sentry.profiler.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..79093089e57e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts @@ -0,0 +1,89 @@ +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(); + + 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(); + + validateProfilePayloadMetadata(envelopeItemPayload2); + + validateProfile(envelopeItemPayload2.profile, { + expectedFunctionNames: [ + 'startJSSelfProfile', + 'fibonacci1', // called by fibonacci2 + 'fibonacci2', + ], + isChunkFormat: true, + }); + + // does not contain fibonacci3 (called during unprofiled part) + const functionNames2 = envelopeItemPayload1.profile.frames.map(frame => frame.function).filter(name => name !== ''); + expect(functionNames2).toEqual(expect.not.arrayContaining(['fibonacci3'])); +}); 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'); From 4e0c6703b25255ca77aecc9e7c772cd75e20d9e2 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 12 Nov 2025 13:49:42 +0100 Subject: [PATCH 02/18] add common UIProfiler --- packages/browser/src/profiling/UIProfiler.ts | 382 ++++++++++--------- 1 file changed, 197 insertions(+), 185 deletions(-) diff --git a/packages/browser/src/profiling/UIProfiler.ts b/packages/browser/src/profiling/UIProfiler.ts index fb7cd022ac7f..b457e197f8ef 100644 --- a/packages/browser/src/profiling/UIProfiler.ts +++ b/packages/browser/src/profiling/UIProfiler.ts @@ -9,101 +9,210 @@ import { getSdkMetadataForEnvelopeHeader, uuid4, } from '@sentry/core'; -import { DEBUG_BUILD } from './../debug-build'; -import type { JSSelfProfiler } from './jsSelfProfiling'; -import { createProfileChunkPayload, startJSSelfProfile, validateProfileChunk } from './utils'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { JSSelfProfiler } from '../jsSelfProfiling'; +import { createProfileChunkPayload, startJSSelfProfile, validateProfileChunk } from '../utils'; +// Unified constants (kept identical to previous implementations) 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 (Browser UI Profiling / 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. + * While running (either mode), we periodically stop and restart the JS self-profiling API + * to emit standalone `profile_chunk` envelopes every 60s and when profiling stops. + * + * Public API surface (used by integration and user-facing profiler hooks): + * - initialize(client, sessionSampled, lifecycleMode) + * - start() + * - stop() + * - notifyRootSpanActive(span) (only meaningful in 'trace' mode) */ export class UIProfiler { 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. - */ - public initialize(client: Client, sessionSampled: boolean): void { - // One Profiler ID per profiling session (user session) + /** Initialize the profiler with client, session sampling and lifecycle mode. */ + public initialize(client: Client, sessionSampled: boolean, lifecycleMode: 'manual' | 'trace'): void { + this._client = client; + this._sessionSampled = sessionSampled; + this._lifecycleMode = lifecycleMode; + + // One profiler ID for the entire profiling session (user session) this._profilerId = uuid4(); - DEBUG_BUILD && debug.log("[Profiling] Initializing profiler (lifecycle='trace')."); + DEBUG_BUILD && debug.log(`[Profiling] Initializing profiler (lifecycle='${lifecycleMode}').`); - this._client = client; - this._sessionSampled = sessionSampled; + if (!sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled; profiler will remain inactive.'); + } + + if (lifecycleMode === 'trace') { + this._setupTraceLifecycleListeners(client); + } + } + + /** Start profiling manually (only effective in 'manual' mode and when sampled). */ + public start(): void { + if (this._lifecycleMode === 'trace') { + DEBUG_BUILD && + debug.log('[Profiling] `profileLifecycle` is set to "trace"; manual start() calls are ignored in trace mode.'); + return; + } + + if (this._isRunning) { + DEBUG_BUILD && debug.log('[Profiling] Profile session already running, no-op.'); + return; + } + + if (!this._sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled, start() is a no-op.'); + return; + } + + this._beginProfiling(); + } + + /** Stop profiling manually (only effective in 'manual' mode). */ + public stop(): void { + if (this._lifecycleMode === 'trace') { + DEBUG_BUILD && + debug.log('[Profiling] `profileLifecycle` is set to "trace"; manual stop() calls are ignored in trace mode.'); + return; + } + + if (!this._isRunning) { + DEBUG_BUILD && debug.log('[Profiling] No profile session running, stop() is a no-op.'); + return; + } + + this._endProfiling(); + } + /** Notify the profiler of a root span active at setup time (used only in trace mode). */ + public notifyRootSpanActive(span: Span): void { + if (this._lifecycleMode !== 'trace' || !this._sessionSampled) { + return; + } + const spanId = span.spanContext().spanId; + if (!spanId || this._activeRootSpanIds.has(spanId)) { + return; + } + this._registerTraceRootSpan(spanId); + } + + /* ========================= Internal Helpers ========================= */ + + /** Begin profiling session (shared path used by manual start or trace activation). */ + private _beginProfiling(): void { + if (this._isRunning) { + return; + } + this._isRunning = true; + + // Expose profiler_id so emitted events can be associated + getGlobalScope().setContext('profile', { profiler_id: this._profilerId }); + + DEBUG_BUILD && debug.log('[Profiling] Started profiling with profiler ID:', this._profilerId); + + this._startProfilerInstance(); + if (!this._profiler) { + DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler; stopping.'); + this._resetProfilerInfo(); + return; + } + + this._startPeriodicChunking(); + } + + /** End profiling session, collect final chunk. */ + private _endProfiling(): void { + if (!this._isRunning) { + return; + } + this._isRunning = false; + + if (this._chunkTimer) { + clearTimeout(this._chunkTimer); + this._chunkTimer = undefined; + } + + // Clear trace-mode timeouts if any + this._clearAllRootSpanTimeouts(); + + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on stop():', e); + }); + + // Clear context so subsequent events aren't marked as profiled + getGlobalScope().setContext('profile', {}); + } + + /** Trace-mode: attach spanStart/spanEnd listeners. */ + private _setupTraceLifecycleListeners(client: Client): void { client.on('spanStart', span => { if (!this._sessionSampled) { - DEBUG_BUILD && debug.log('[Profiling] Session not sampled because of negative sampling decision.'); + DEBUG_BUILD && debug.log('[Profiling] Session not sampled; ignoring spanStart.'); return; } if (span !== getRootSpan(span)) { - return; + return; // only care about root spans } // Only count sampled root spans if (!span.isRecording()) { - DEBUG_BUILD && debug.log('[Profiling] Discarding profile because root span was not sampled.'); + DEBUG_BUILD && debug.log('[Profiling] Ignoring non-sampled root span.'); 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)) { + if (!spanId || this._activeRootSpanIds.has(spanId)) { return; } + this._registerTraceRootSpan(spanId); - 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) { + const count = this._activeRootSpanIds.size; + if (count === 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}).`, + `[Profiling] Root span ${spanId} started. Profiling active while there are active root spans (count=${count}).`, ); - - this.start(); + this._beginProfiling(); } }); @@ -111,134 +220,79 @@ export class UIProfiler { 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) { + const count = this._activeRootSpanIds.size; + DEBUG_BUILD && debug.log(`[Profiling] Root span ${spanId} ended. Remaining active root spans (count=${count}).`); + + if (count === 0) { + // Collect final chunk before stopping this._collectCurrentChunk().catch(e => { - DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `spanEnd`:', e); + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on last spanEnd:', e); }); - - this.stop(); + this._endProfiling(); } }); } - /** - * 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; - } - + /** Register root span and schedule safeguard timeout (trace mode). */ + private _registerTraceRootSpan(spanId: string): void { 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(); - } + const timeout = setTimeout(() => this._onRootSpanTimeout(spanId), MAX_ROOT_SPAN_PROFILE_MS); + this._rootSpanTimeouts.set(spanId, timeout); } - /** - * Start profiling if not already running. - */ - public start(): void { - if (this._isRunning) { - return; + /** Root span timeout handler (trace mode). */ + private _onRootSpanTimeout(spanId: string): void { + if (!this._rootSpanTimeouts.has(spanId)) { + return; // span already ended } - this._isRunning = true; + this._rootSpanTimeouts.delete(spanId); - 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(); + if (!this._activeRootSpanIds.has(spanId)) { return; } - this._startPeriodicChunking(); - } + DEBUG_BUILD && + debug.log(`[Profiling] Reached 5-minute timeout for root span ${spanId}. Did you forget to call .end()?`); - /** - * Stop profiling; final chunk will be collected and sent. - */ - public stop(): void { - if (!this._isRunning) { - return; - } + this._activeRootSpanIds.delete(spanId); - this._isRunning = false; - if (this._chunkTimer) { - clearTimeout(this._chunkTimer); - this._chunkTimer = undefined; + if (this._activeRootSpanIds.size === 0) { + this._endProfiling(); } + } - 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); - }); + /** Clear all trace-mode root span timeouts. */ + private _clearAllRootSpanTimeouts(): void { + this._rootSpanTimeouts.forEach(t => clearTimeout(t)); + this._rootSpanTimeouts.clear(); } - /** - * Resets profiling information from scope and resets running state - */ + /** Reset running state and profiling context (used on failure). */ 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. - */ + /** Start JS self profiler instance if needed. */ 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 self profiler.'); 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. - */ + /** Schedule periodic chunk collection while running. */ private _startPeriodicChunking(): void { if (!this._isRunning) { return; @@ -246,94 +300,52 @@ export class UIProfiler { this._chunkTimer = setTimeout(() => { this._collectCurrentChunk().catch(e => { - DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk during periodic chunking:', e); + DEBUG_BUILD && debug.error('[Profiling] Failed to collect profile chunk during periodic chunking:', e); }); if (this._isRunning) { this._startProfilerInstance(); - if (!this._profiler) { - // If restart failed, stop scheduling further chunks and reset context. + // Could not restart -> stop profiling gracefully 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. - */ + /** Stop current profiler instance, convert profile to chunk & send. */ private async _collectCurrentChunk(): Promise { - const prevProfiler = this._profiler; + const prev = this._profiler; this._profiler = undefined; - - if (!prevProfiler) { + if (!prev) { return; } - try { - const profile = await prevProfiler.stop(); - + const profile = await prev.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) { + const validation = validateProfileChunk(chunk); + if ('reason' in validation) { DEBUG_BUILD && debug.log( '[Profiling] Discarding invalid profile chunk (this is probably a bug in the SDK):', - validationReturn.reason, + validation.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. - */ + /** 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; From 47b89b1668cef7fbd8a75ad0f478caa0d3851633 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 12 Nov 2025 14:57:43 +0100 Subject: [PATCH 03/18] align with previous code --- packages/browser/src/profiling/UIProfiler.ts | 135 ++++++++++--------- 1 file changed, 72 insertions(+), 63 deletions(-) diff --git a/packages/browser/src/profiling/UIProfiler.ts b/packages/browser/src/profiling/UIProfiler.ts index b457e197f8ef..437c2f3ccb5c 100644 --- a/packages/browser/src/profiling/UIProfiler.ts +++ b/packages/browser/src/profiling/UIProfiler.ts @@ -63,15 +63,14 @@ export class UIProfiler { /** Initialize the profiler with client, session sampling and lifecycle mode. */ public initialize(client: Client, sessionSampled: boolean, lifecycleMode: 'manual' | 'trace'): void { - this._client = client; - this._sessionSampled = sessionSampled; - this._lifecycleMode = lifecycleMode; - - // One profiler ID for the entire profiling session (user session) this._profilerId = uuid4(); DEBUG_BUILD && debug.log(`[Profiling] Initializing profiler (lifecycle='${lifecycleMode}').`); + this._client = client; + this._sessionSampled = sessionSampled; + this._lifecycleMode = lifecycleMode; + if (!sessionSampled) { DEBUG_BUILD && debug.log('[Profiling] Session not sampled; profiler will remain inactive.'); } @@ -118,33 +117,42 @@ export class UIProfiler { this._endProfiling(); } - /** Notify the profiler of a root span active at setup time (used only in trace mode). */ + /** Handle an already-active root span at integration setup time (used only in trace mode). */ public notifyRootSpanActive(span: Span): void { if (this._lifecycleMode !== 'trace' || !this._sessionSampled) { return; } + const spanId = span.spanContext().spanId; if (!spanId || this._activeRootSpanIds.has(spanId)) { return; } this._registerTraceRootSpan(spanId); - } - /* ========================= Internal Helpers ========================= */ + 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._beginProfiling(); + } + } - /** Begin profiling session (shared path used by manual start or trace activation). */ + /** Begin profiling if not already running. */ private _beginProfiling(): void { if (this._isRunning) { return; } this._isRunning = true; - // Expose profiler_id so emitted events can be associated - getGlobalScope().setContext('profile', { profiler_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 }); + this._startProfilerInstance(); + if (!this._profiler) { DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler; stopping.'); this._resetProfilerInfo(); @@ -154,7 +162,7 @@ export class UIProfiler { this._startPeriodicChunking(); } - /** End profiling session, collect final chunk. */ + /** End profiling session; final chunk will be collected and sent. */ private _endProfiling(): void { if (!this._isRunning) { return; @@ -166,22 +174,24 @@ export class UIProfiler { this._chunkTimer = undefined; } - // Clear trace-mode timeouts if any this._clearAllRootSpanTimeouts(); this._collectCurrentChunk().catch(e => { DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on stop():', e); }); - // Clear context so subsequent events aren't marked as profiled - getGlobalScope().setContext('profile', {}); + // Clear context so subsequent events aren't marked as profiled in manual mode. + // todo: test in manual mode + if (this._lifecycleMode === 'manual') { + getGlobalScope().setContext('profile', {}); + } } /** Trace-mode: attach spanStart/spanEnd listeners. */ private _setupTraceLifecycleListeners(client: Client): void { client.on('spanStart', span => { if (!this._sessionSampled) { - DEBUG_BUILD && debug.log('[Profiling] Session not sampled; ignoring spanStart.'); + DEBUG_BUILD && debug.log('[Profiling] Session not sampled because of negative sampling decision.'); return; } if (span !== getRootSpan(span)) { @@ -189,28 +199,26 @@ export class UIProfiler { } // Only count sampled root spans if (!span.isRecording()) { - DEBUG_BUILD && debug.log('[Profiling] Ignoring non-sampled root span.'); + 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, - }); - */ + // Match emitted chunks with events: set profiler_id on global scope + // do I need this? + // getGlobalScope().setContext('profile', { profiler_id: this._profilerId }); const spanId = span.spanContext().spanId; if (!spanId || this._activeRootSpanIds.has(spanId)) { return; } + this._registerTraceRootSpan(spanId); - const count = this._activeRootSpanIds.size; - if (count === 1) { + const rootSpanCount = this._activeRootSpanIds.size; + if (rootSpanCount === 1) { DEBUG_BUILD && debug.log( - `[Profiling] Root span ${spanId} started. Profiling active while there are active root spans (count=${count}).`, + `[Profiling] Root span ${spanId} started. Profiling active while there are active root spans (count=${rootSpanCount}).`, ); this._beginProfiling(); } @@ -225,46 +233,25 @@ export class UIProfiler { return; } this._activeRootSpanIds.delete(spanId); + const rootSpanCount = this._activeRootSpanIds.size; - const count = this._activeRootSpanIds.size; - DEBUG_BUILD && debug.log(`[Profiling] Root span ${spanId} ended. Remaining active root spans (count=${count}).`); - - if (count === 0) { - // Collect final chunk before stopping + 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 last spanEnd:', e); + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on last `spanEnd`:', e); }); this._endProfiling(); } }); } - /** Register root span 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); - this._rootSpanTimeouts.set(spanId, timeout); - } - - /** Root span timeout handler (trace mode). */ - private _onRootSpanTimeout(spanId: string): void { - if (!this._rootSpanTimeouts.has(spanId)) { - return; // span already ended - } - this._rootSpanTimeouts.delete(spanId); - - if (!this._activeRootSpanIds.has(spanId)) { - return; - } - - DEBUG_BUILD && - debug.log(`[Profiling] Reached 5-minute timeout for root span ${spanId}. Did you forget to call .end()?`); - - this._activeRootSpanIds.delete(spanId); - - if (this._activeRootSpanIds.size === 0) { - this._endProfiling(); - } + /** Reset running state and profiling context (used on failure). */ + private _resetProfilerInfo(): void { + this._isRunning = false; + getGlobalScope().setContext('profile', {}); } /** Clear all trace-mode root span timeouts. */ @@ -273,10 +260,11 @@ export class UIProfiler { this._rootSpanTimeouts.clear(); } - /** Reset running state and profiling context (used on failure). */ - private _resetProfilerInfo(): void { - this._isRunning = false; - getGlobalScope().setContext('profile', {}); + /** Register root span 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); + this._rootSpanTimeouts.set(spanId, timeout); } /** Start JS self profiler instance if needed. */ @@ -315,6 +303,27 @@ export class UIProfiler { }, CHUNK_INTERVAL_MS); } + /** Root span timeout handler (trace mode). */ + private _onRootSpanTimeout(spanId: string): void { + if (!this._rootSpanTimeouts.has(spanId)) { + return; // span already ended + } + this._rootSpanTimeouts.delete(spanId); + + if (!this._activeRootSpanIds.has(spanId)) { + return; + } + + DEBUG_BUILD && + debug.log(`[Profiling] Reached 5-minute timeout for root span ${spanId}. Did you forget to call .end()?`); + + this._activeRootSpanIds.delete(spanId); + + if (this._activeRootSpanIds.size === 0) { + this._endProfiling(); + } + } + /** Stop current profiler instance, convert profile to chunk & send. */ private async _collectCurrentChunk(): Promise { const prev = this._profiler; From ea381955bdb6898b846a09a2045637a7090811e0 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 12 Nov 2025 16:50:38 +0100 Subject: [PATCH 04/18] prepare profiling for manual mode --- packages/browser/src/profiling/UIProfiler.ts | 100 ++++++++++-------- packages/browser/src/profiling/integration.ts | 2 +- 2 files changed, 58 insertions(+), 44 deletions(-) diff --git a/packages/browser/src/profiling/UIProfiler.ts b/packages/browser/src/profiling/UIProfiler.ts index 437c2f3ccb5c..35cb0dbe7585 100644 --- a/packages/browser/src/profiling/UIProfiler.ts +++ b/packages/browser/src/profiling/UIProfiler.ts @@ -9,28 +9,23 @@ import { getSdkMetadataForEnvelopeHeader, uuid4, } from '@sentry/core'; -import { DEBUG_BUILD } from '../../debug-build'; -import type { JSSelfProfiler } from '../jsSelfProfiling'; -import { createProfileChunkPayload, startJSSelfProfile, validateProfileChunk } from '../utils'; +import { DEBUG_BUILD } from './../debug-build'; +import type { JSSelfProfiler } from './jsSelfProfiling'; +import { createProfileChunkPayload, startJSSelfProfile, validateProfileChunk } from './utils'; -// Unified constants (kept identical to previous implementations) 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 max per root span in trace mode /** - * UIProfiler (Browser UI Profiling / Profiling V2) + * UIProfiler (Profiling V2): * Supports two lifecycle modes: * - 'manual': controlled explicitly via start()/stop() * - 'trace': automatically runs while there are active sampled root spans * - * While running (either mode), we periodically stop and restart the JS self-profiling API - * to emit standalone `profile_chunk` envelopes every 60s and when profiling stops. - * - * Public API surface (used by integration and user-facing profiler hooks): - * - initialize(client, sessionSampled, lifecycleMode) - * - start() - * - stop() - * - notifyRootSpanActive(span) (only meaningful in 'trace' mode) + * 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 { private _client: Client | undefined; @@ -63,6 +58,7 @@ export class UIProfiler { /** Initialize the profiler with client, session sampling and lifecycle mode. */ public initialize(client: Client, sessionSampled: boolean, lifecycleMode: 'manual' | 'trace'): void { + // One Profiler ID per profiling session (user session) this._profilerId = uuid4(); DEBUG_BUILD && debug.log(`[Profiling] Initializing profiler (lifecycle='${lifecycleMode}').`); @@ -71,10 +67,6 @@ export class UIProfiler { this._sessionSampled = sessionSampled; this._lifecycleMode = lifecycleMode; - if (!sessionSampled) { - DEBUG_BUILD && debug.log('[Profiling] Session not sampled; profiler will remain inactive.'); - } - if (lifecycleMode === 'trace') { this._setupTraceLifecycleListeners(client); } @@ -118,16 +110,17 @@ export class UIProfiler { } /** Handle an already-active root span at integration setup time (used only in trace mode). */ - public notifyRootSpanActive(span: Span): void { + public notifyRootSpanActive(rootSpan: Span): void { if (this._lifecycleMode !== 'trace' || !this._sessionSampled) { return; } - const spanId = span.spanContext().spanId; + const spanId = rootSpan.spanContext().spanId; if (!spanId || this._activeRootSpanIds.has(spanId)) { return; } - this._registerTraceRootSpan(spanId); + + this._activeRootSpanIds.add(spanId); const rootSpanCount = this._activeRootSpanIds.size; @@ -176,8 +169,9 @@ export class UIProfiler { 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); + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `stop()`:', e); }); // Clear context so subsequent events aren't marked as profiled in manual mode. @@ -204,7 +198,7 @@ export class UIProfiler { } // Match emitted chunks with events: set profiler_id on global scope - // do I need this? + // todo: do I need this? // getGlobalScope().setContext('profile', { profiler_id: this._profilerId }); const spanId = span.spanContext().spanId; @@ -254,9 +248,9 @@ export class UIProfiler { getGlobalScope().setContext('profile', {}); } - /** Clear all trace-mode root span timeouts. */ + /** Clear and reset all per-root-span timeouts. */ private _clearAllRootSpanTimeouts(): void { - this._rootSpanTimeouts.forEach(t => clearTimeout(t)); + this._rootSpanTimeouts.forEach(timeout => clearTimeout(timeout)); this._rootSpanTimeouts.clear(); } @@ -267,20 +261,24 @@ export class UIProfiler { this._rootSpanTimeouts.set(spanId, timeout); } - /** Start JS self profiler instance if needed. */ + /** Start a profiler instance if needed. */ private _startProfilerInstance(): void { if (this._profiler?.stopped === false) { return; // already running } const profiler = startJSSelfProfile(); if (!profiler) { - DEBUG_BUILD && debug.log('[Profiling] Failed to start JS self profiler.'); + DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler.'); return; } this._profiler = profiler; } - /** Schedule periodic chunk collection while running. */ + /** + * 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; @@ -288,36 +286,44 @@ export class UIProfiler { this._chunkTimer = setTimeout(() => { this._collectCurrentChunk().catch(e => { - DEBUG_BUILD && debug.error('[Profiling] Failed to collect profile chunk during periodic chunking:', e); + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk during periodic chunking:', e); }); if (this._isRunning) { this._startProfilerInstance(); + if (!this._profiler) { - // Could not restart -> stop profiling gracefully + // If restart failed, stop scheduling further chunks and reset context. this._resetProfilerInfo(); return; } + this._startPeriodicChunking(); } }, CHUNK_INTERVAL_MS); } - /** Root span timeout handler (trace mode). */ - private _onRootSpanTimeout(spanId: string): void { - if (!this._rootSpanTimeouts.has(spanId)) { - return; // span already ended + /** + * 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(spanId); + this._rootSpanTimeouts.delete(rootSpanId); - if (!this._activeRootSpanIds.has(spanId)) { + if (!this._activeRootSpanIds.has(rootSpanId)) { return; } DEBUG_BUILD && - debug.log(`[Profiling] Reached 5-minute timeout for root span ${spanId}. Did you forget to call .end()?`); + 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(spanId); + this._activeRootSpanIds.delete(rootSpanId); if (this._activeRootSpanIds.size === 0) { this._endProfiling(); @@ -326,25 +332,32 @@ export class UIProfiler { /** Stop current profiler instance, convert profile to chunk & send. */ private async _collectCurrentChunk(): Promise { - const prev = this._profiler; + const prevProfiler = this._profiler; this._profiler = undefined; - if (!prev) { + + if (!prevProfiler) { return; } + try { - const profile = await prev.stop(); + const profile = await prevProfiler.stop(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const chunk = createProfileChunkPayload(profile, this._client!, this._profilerId); - const validation = validateProfileChunk(chunk); - if ('reason' in validation) { + + // 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):', - validation.reason, + 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); @@ -355,6 +368,7 @@ export class UIProfiler { 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; diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 7cd1886e636d..db870ad7be55 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -66,7 +66,7 @@ const _browserProfilingIntegration = (() => { } const traceLifecycleProfiler = new UIProfiler(); - traceLifecycleProfiler.initialize(client, sessionSampled); + traceLifecycleProfiler.initialize(client, sessionSampled, lifecycleMode); // If there is an active, sampled root span already, notify the profiler if (rootSpan) { From 32080bf402b675585f463718d17dc5a8838b95d7 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 13 Nov 2025 16:37:23 +0100 Subject: [PATCH 05/18] add comment about node profiling namespace --- packages/core/src/profiling.ts | 5 +++++ 1 file changed, 5 insertions(+) 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, From 98b6de26480d5dfb69ff0cae2e9afdc5a531eaf8 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:15:20 +0100 Subject: [PATCH 06/18] improve integration tests --- .../suites/profiling/manualMode/subject.js | 12 ++++-------- .../suites/profiling/manualMode/test.ts | 4 ++++ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js b/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js index 35855580cc3a..906f14d06693 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js +++ b/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js @@ -48,9 +48,7 @@ function notProfiledFib(n) { // Adding setTimeout to ensure we cross the sampling interval to avoid flakes -// --- - -Sentry.profiler.startProfiler(); +Sentry.uiProfiler.startProfiler(); fibonacci(40); await new Promise(resolve => setTimeout(resolve, 25)); @@ -58,7 +56,7 @@ await new Promise(resolve => setTimeout(resolve, 25)); largeSum(); await new Promise(resolve => setTimeout(resolve, 25)); -Sentry.profiler.stopProfiler(); +Sentry.uiProfiler.stopProfiler(); // --- @@ -67,14 +65,12 @@ await new Promise(resolve => setTimeout(resolve, 25)); // --- -Sentry.profiler.startProfiler(); +Sentry.uiProfiler.startProfiler(); fibonacci2(40); await new Promise(resolve => setTimeout(resolve, 25)); -Sentry.profiler.stopProfiler(); - -// --- +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 index 79093089e57e..810326e66adb 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts @@ -51,6 +51,8 @@ sentryTest('sends profile_chunk envelopes in manual mode', async ({ page, getLoc expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); expect(envelopeItemPayload1.profile).toBeDefined(); + const profilerId1 = envelopeItemPayload1.profiler_id; + validateProfilePayloadMetadata(envelopeItemPayload1); validateProfile(envelopeItemPayload1.profile, { @@ -72,6 +74,8 @@ sentryTest('sends profile_chunk envelopes in manual mode', async ({ page, getLoc 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, { From ff7fc667cf4da1242163faa3ff47a5552d2784cc Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 14 Nov 2025 15:36:22 +0100 Subject: [PATCH 07/18] add util in test --- .../browser/test/profiling/UIProfiler.test.ts | 97 +++++-------------- 1 file changed, 25 insertions(+), 72 deletions(-) diff --git a/packages/browser/test/profiling/UIProfiler.test.ts b/packages/browser/test/profiling/UIProfiler.test.ts index f28880960256..a79eaac48bc6 100644 --- a/packages/browser/test/profiling/UIProfiler.test.ts +++ b/packages/browser/test/profiling/UIProfiler.test.ts @@ -5,6 +5,19 @@ import * as Sentry from '@sentry/browser'; import type { Span } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +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 +61,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 +87,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 +115,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 +157,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 +188,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 +243,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 +291,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 +353,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 +413,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 +467,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 +526,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 +556,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 }, () => { From 389a6a51cf789e0a0b6c15c34163eadccf104e66 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 14 Nov 2025 16:01:46 +0100 Subject: [PATCH 08/18] add test, refactor --- packages/browser/src/exports.ts | 1 + packages/browser/src/profiling/UIProfiler.ts | 29 ++- packages/browser/src/profiling/index.ts | 76 ++++++++ packages/browser/src/profiling/integration.ts | 30 +-- packages/browser/src/profiling/utils.ts | 9 +- .../browser/test/profiling/UIProfiler.test.ts | 184 +++++++++++++++++- 6 files changed, 296 insertions(+), 33 deletions(-) create mode 100644 packages/browser/src/profiling/index.ts 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 35cb0dbe7585..b461c985c7b9 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,9 +9,10 @@ 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) @@ -27,7 +28,7 @@ const MAX_ROOT_SPAN_PROFILE_MS = 300_000; // 5 minutes max per root span in trac * - 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; @@ -57,12 +58,18 @@ export class UIProfiler { } /** Initialize the profiler with client, session sampling and lifecycle mode. */ - public initialize(client: Client, sessionSampled: boolean, lifecycleMode: 'manual' | 'trace'): 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}').`); + 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; @@ -173,12 +180,6 @@ export class UIProfiler { this._collectCurrentChunk().catch(e => { DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `stop()`:', e); }); - - // Clear context so subsequent events aren't marked as profiled in manual mode. - // todo: test in manual mode - if (this._lifecycleMode === 'manual') { - getGlobalScope().setContext('profile', {}); - } } /** Trace-mode: attach spanStart/spanEnd listeners. */ @@ -197,10 +198,6 @@ export class UIProfiler { return; } - // Match emitted chunks with events: set profiler_id on global scope - // todo: do I need this? - // getGlobalScope().setContext('profile', { profiler_id: this._profilerId }); - const spanId = span.spanContext().spanId; if (!spanId || this._activeRootSpanIds.has(spanId)) { return; diff --git a/packages/browser/src/profiling/index.ts b/packages/browser/src/profiling/index.ts new file mode 100644 index 000000000000..94757be9ab1c --- /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 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 db870ad7be55..bd1ddf3019da 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; @@ -49,14 +49,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 +62,13 @@ const _browserProfilingIntegration = (() => { return; } - const traceLifecycleProfiler = new UIProfiler(); - traceLifecycleProfiler.initialize(client, sessionSampled, lifecycleMode); + this._profiler.initialize(client); // If there is an active, sampled root span already, notify the profiler if (rootSpan) { - traceLifecycleProfiler.notifyRootSpanActive(rootSpan); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore accessing integration instance property + this._profiler.notifyRootSpanActive(rootSpan); } // In case rootSpan is created slightly after setup -> schedule microtask to re-check and notify. @@ -78,7 +76,9 @@ const _browserProfilingIntegration = (() => { const laterActiveSpan = getActiveSpan(); const laterRootSpan = laterActiveSpan && getRootSpan(laterActiveSpan); if (laterRootSpan) { - traceLifecycleProfiler.notifyRootSpanActive(laterRootSpan); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore accessing integration instance property + this._profiler.notifyRootSpanActive(laterRootSpan); } }, 0); } @@ -154,6 +154,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..1d5fc55e0752 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -688,18 +688,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; } diff --git a/packages/browser/test/profiling/UIProfiler.test.ts b/packages/browser/test/profiling/UIProfiler.test.ts index a79eaac48bc6..c6d94976f568 100644 --- a/packages/browser/test/profiling/UIProfiler.test.ts +++ b/packages/browser/test/profiling/UIProfiler.test.ts @@ -4,7 +4,6 @@ import * as Sentry from '@sentry/browser'; import type { Span } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { BrowserOptions } from '../../src/index'; @@ -582,3 +581,186 @@ describe('Browser Profiling v2 trace lifecycle', () => { }); }); }); + +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 does nothing', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + }); + + Sentry.uiProfiler.startProfiler(); + Sentry.uiProfiler.startProfiler(); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + expect(stop).toHaveBeenCalledTimes(1); + }); + + it('profileSessionSampleRate is required', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileLifecycle: 'manual', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.uiProfiler.startProfiler(); + 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), + }), + }); + }); + }); +}); From 53bdb13418c577342502f5d60608ded38614eb4f Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 17 Nov 2025 11:17:41 +0100 Subject: [PATCH 09/18] revert comment changes --- packages/browser/src/profiling/UIProfiler.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/browser/src/profiling/UIProfiler.ts b/packages/browser/src/profiling/UIProfiler.ts index b461c985c7b9..f6ee519bcf71 100644 --- a/packages/browser/src/profiling/UIProfiler.ts +++ b/packages/browser/src/profiling/UIProfiler.ts @@ -57,7 +57,9 @@ export class UIProfiler implements ContinuousProfiler { this._rootSpanTimeouts = new Map(); } - /** Initialize the profiler with client, session sampling and lifecycle mode. */ + /** + * Initialize the profiler with client, session sampling and lifecycle mode. + */ public initialize(client: Client): void { const lifecycleMode = (client.getOptions() as BrowserOptions).profileLifecycle; const sessionSampled = shouldProfileSession(client.getOptions()); @@ -139,7 +141,9 @@ export class UIProfiler implements ContinuousProfiler { } } - /** Begin profiling if not already running. */ + /** + * Begin profiling if not already running. + */ private _beginProfiling(): void { if (this._isRunning) { return; @@ -245,7 +249,9 @@ export class UIProfiler implements ContinuousProfiler { getGlobalScope().setContext('profile', {}); } - /** Clear and reset all per-root-span timeouts. */ + /** + * Clear and reset all per-root-span timeouts. + */ private _clearAllRootSpanTimeouts(): void { this._rootSpanTimeouts.forEach(timeout => clearTimeout(timeout)); this._rootSpanTimeouts.clear(); @@ -327,7 +333,9 @@ export class UIProfiler implements ContinuousProfiler { } } - /** Stop current profiler instance, convert profile to chunk & send. */ + /** + * Stop current profiler instance, convert profile to chunk & send. + */ private async _collectCurrentChunk(): Promise { const prevProfiler = this._profiler; this._profiler = undefined; @@ -361,7 +369,9 @@ export class UIProfiler implements ContinuousProfiler { } } - /** Send a profile chunk as a standalone envelope. */ + /** + * 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!; From 09bfd6c840c030bec48e51e060cb72e62ae008ac Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 17 Nov 2025 11:44:48 +0100 Subject: [PATCH 10/18] add tests for logs --- packages/browser/src/profiling/UIProfiler.ts | 14 +++-- .../browser/test/profiling/UIProfiler.test.ts | 56 ++++++++++++++++--- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/packages/browser/src/profiling/UIProfiler.ts b/packages/browser/src/profiling/UIProfiler.ts index f6ee519bcf71..ea0ab87f9a2b 100644 --- a/packages/browser/src/profiling/UIProfiler.ts +++ b/packages/browser/src/profiling/UIProfiler.ts @@ -85,17 +85,19 @@ export class UIProfiler implements ContinuousProfiler { public start(): void { if (this._lifecycleMode === 'trace') { DEBUG_BUILD && - debug.log('[Profiling] `profileLifecycle` is set to "trace"; manual start() calls are ignored in trace mode.'); + debug.warn( + '[Profiling] `profileLifecycle` is set to "trace". Calls to `uiProfiler.start()` are ignored in trace mode.', + ); return; } if (this._isRunning) { - DEBUG_BUILD && debug.log('[Profiling] Profile session already running, no-op.'); + DEBUG_BUILD && debug.warn('[Profiling] Profile session is already running, `uiProfiler.start()` is a no-op.'); return; } if (!this._sessionSampled) { - DEBUG_BUILD && debug.log('[Profiling] Session not sampled, start() is a no-op.'); + DEBUG_BUILD && debug.warn('[Profiling] Session is not sampled, `uiProfiler.start()` is a no-op.'); return; } @@ -106,12 +108,14 @@ export class UIProfiler implements ContinuousProfiler { public stop(): void { if (this._lifecycleMode === 'trace') { DEBUG_BUILD && - debug.log('[Profiling] `profileLifecycle` is set to "trace"; manual stop() calls are ignored in trace mode.'); + debug.warn( + '[Profiling] `profileLifecycle` is set to "trace". Calls to `uiProfiler.stop()` are ignored in trace mode.', + ); return; } if (!this._isRunning) { - DEBUG_BUILD && debug.log('[Profiling] No profile session running, stop() is a no-op.'); + DEBUG_BUILD && debug.warn('[Profiling] Profile session is already running, `uiProfiler.stop()` is a no-op.'); return; } diff --git a/packages/browser/test/profiling/UIProfiler.test.ts b/packages/browser/test/profiling/UIProfiler.test.ts index c6d94976f568..72d1d95b4efd 100644 --- a/packages/browser/test/profiling/UIProfiler.test.ts +++ b/packages/browser/test/profiling/UIProfiler.test.ts @@ -3,7 +3,7 @@ */ import * as Sentry from '@sentry/browser'; -import type { Span } from '@sentry/core'; +import { type Span, debug } from '@sentry/core'; import { type Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { BrowserOptions } from '../../src/index'; @@ -580,6 +580,36 @@ 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 { @@ -651,40 +681,52 @@ describe('Browser Profiling v2 manual lifecycle', () => { expect(envelopeHeader?.type).toBe('profile_chunk'); }); - it('calling start and stop while profile session is running does nothing', async () => { + 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] Profile session is already 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({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileLifecycle: 'manual', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...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(); From 68847c3f69444f713d0233069fec624613aef6a6 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 17 Nov 2025 13:41:30 +0100 Subject: [PATCH 11/18] remove profile_id between cycles --- packages/browser/src/profiling/UIProfiler.ts | 6 + packages/browser/src/profiling/index.ts | 2 +- .../lifecycleMode/manualLifecycleProfiler.ts | 218 ++++++++++++++++++ .../browser/test/profiling/UIProfiler.test.ts | 44 ++++ 4 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 packages/browser/src/profiling/lifecycleMode/manualLifecycleProfiler.ts diff --git a/packages/browser/src/profiling/UIProfiler.ts b/packages/browser/src/profiling/UIProfiler.ts index ea0ab87f9a2b..25412018dd05 100644 --- a/packages/browser/src/profiling/UIProfiler.ts +++ b/packages/browser/src/profiling/UIProfiler.ts @@ -188,6 +188,12 @@ export class UIProfiler implements ContinuousProfiler { 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 as long as there is an active root span + if (this._lifecycleMode === 'manual') { + getGlobalScope().setContext('profile', {}); + } } /** Trace-mode: attach spanStart/spanEnd listeners. */ diff --git a/packages/browser/src/profiling/index.ts b/packages/browser/src/profiling/index.ts index 94757be9ab1c..32d4c94fe55c 100644 --- a/packages/browser/src/profiling/index.ts +++ b/packages/browser/src/profiling/index.ts @@ -66,7 +66,7 @@ function stopProfiler(): void { } /** - * Profiler namespace for controlling the profiler in 'manual' mode. + * Profiler namespace for controlling the JS profiler in 'manual' mode. * * Requires the `browserProfilingIntegration` from the `@sentry/browser` package. */ diff --git a/packages/browser/src/profiling/lifecycleMode/manualLifecycleProfiler.ts b/packages/browser/src/profiling/lifecycleMode/manualLifecycleProfiler.ts new file mode 100644 index 000000000000..ff438f37b76d --- /dev/null +++ b/packages/browser/src/profiling/lifecycleMode/manualLifecycleProfiler.ts @@ -0,0 +1,218 @@ +import type { Client, ProfileChunk } from '@sentry/core'; +import { + type ProfileChunkEnvelope, + createEnvelope, + debug, + dsnToString, + getGlobalScope, + 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 + +/** + * Browser manual-lifecycle profiler (UI Profiling / Profiling V2): + * - Controlled via explicit start()/stop() calls + * - While running, periodically stops and restarts the JS self-profiling API to collect chunks + * - Emits standalone `profile_chunk` envelopes on each chunk collection and on stop() + */ +export class BrowserManualLifecycleProfiler { + private _client: Client | undefined; + private _profiler: JSSelfProfiler | undefined; + private _chunkTimer: ReturnType | undefined; + private _profilerId: string | undefined; + private _isRunning: boolean; + private _sessionSampled: boolean; + private _lifecycleMode: 'manual' | 'trace' | undefined; + + public constructor() { + this._client = undefined; + this._profiler = undefined; + this._chunkTimer = undefined; + this._profilerId = undefined; + this._isRunning = false; + this._sessionSampled = false; + this._lifecycleMode = undefined; + } + + /** Initialize the profiler with client, session sampling and (optionally) lifecycle mode for no-op warnings. */ + public initialize(client: Client, sessionSampled: boolean, lifecycleMode?: 'manual' | 'trace'): void { + this._client = client; + this._sessionSampled = sessionSampled; + this._lifecycleMode = lifecycleMode; + // One Profiler ID per profiling session (user session) + this._profilerId = uuid4(); + + DEBUG_BUILD && debug.log("[Profiling] Initializing profiler (lifecycle='manual')."); + } + + /** Start profiling if not already running. No-ops (with debug logs) when not sampled or in 'trace' mode. */ + public start(): void { + if (this._isRunning) { + DEBUG_BUILD && debug.log('[Profiling] Profile session already running, no-op.'); + return; + } + + if (!this._sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled, start() is a no-op.'); + return; + } + + if (this._lifecycleMode === 'trace') { + DEBUG_BUILD && + debug.log( + '[Profiling] `profileLifecycle` is set to \"trace\"; manual start/stop calls are ignored in trace mode.', + ); + return; + } + + this._isRunning = true; + // Match emitted chunks with events + getGlobalScope().setContext('profile', { profiler_id: this._profilerId }); + + this._startProfilerInstance(); + + if (!this._profiler) { + DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler in manual lifecycle. Stopping.'); + this._resetProfilerInfo(); + return; + } + + this._startPeriodicChunking(); + } + + /** Stop profiling; final chunk will be collected and sent. */ + public stop(): void { + if (!this._isRunning) { + DEBUG_BUILD && debug.log('[Profiling] No profile session running, stop() is a no-op.'); + return; + } + + if (this._lifecycleMode === 'trace') { + DEBUG_BUILD && + debug.log( + '[Profiling] `profileLifecycle` is set to \"trace\"; manual start/stop calls are ignored in trace mode.', + ); + return; + } + + this._isRunning = false; + if (this._chunkTimer) { + clearTimeout(this._chunkTimer); + this._chunkTimer = undefined; + } + + // Collect whatever was currently recording + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `stop()`:', e); + }); + + // Clear profiling context so subsequent events aren't marked as profiled + getGlobalScope().setContext('profile', {}); + } + + /** Resets profiling info and running state. */ + private _resetProfilerInfo(): void { + this._isRunning = false; + getGlobalScope().setContext('profile', {}); + } + + /** 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.'); + return; + } + this._profiler = profiler; + } + + /** Schedule periodic chunking while running. */ + 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); + } + + /** 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); + + 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/test/profiling/UIProfiler.test.ts b/packages/browser/test/profiling/UIProfiler.test.ts index 72d1d95b4efd..5e48694fcc04 100644 --- a/packages/browser/test/profiling/UIProfiler.test.ts +++ b/packages/browser/test/profiling/UIProfiler.test.ts @@ -804,5 +804,49 @@ describe('Browser Profiling v2 manual lifecycle', () => { }), }); }); + + 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 + }); }); }); From d03bba36d32edf844003d36f9c50c0e4bd3d592b Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 17 Nov 2025 13:42:41 +0100 Subject: [PATCH 12/18] remove lifecycle folder --- .../lifecycleMode/manualLifecycleProfiler.ts | 218 ------------------ 1 file changed, 218 deletions(-) delete mode 100644 packages/browser/src/profiling/lifecycleMode/manualLifecycleProfiler.ts diff --git a/packages/browser/src/profiling/lifecycleMode/manualLifecycleProfiler.ts b/packages/browser/src/profiling/lifecycleMode/manualLifecycleProfiler.ts deleted file mode 100644 index ff438f37b76d..000000000000 --- a/packages/browser/src/profiling/lifecycleMode/manualLifecycleProfiler.ts +++ /dev/null @@ -1,218 +0,0 @@ -import type { Client, ProfileChunk } from '@sentry/core'; -import { - type ProfileChunkEnvelope, - createEnvelope, - debug, - dsnToString, - getGlobalScope, - 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 - -/** - * Browser manual-lifecycle profiler (UI Profiling / Profiling V2): - * - Controlled via explicit start()/stop() calls - * - While running, periodically stops and restarts the JS self-profiling API to collect chunks - * - Emits standalone `profile_chunk` envelopes on each chunk collection and on stop() - */ -export class BrowserManualLifecycleProfiler { - private _client: Client | undefined; - private _profiler: JSSelfProfiler | undefined; - private _chunkTimer: ReturnType | undefined; - private _profilerId: string | undefined; - private _isRunning: boolean; - private _sessionSampled: boolean; - private _lifecycleMode: 'manual' | 'trace' | undefined; - - public constructor() { - this._client = undefined; - this._profiler = undefined; - this._chunkTimer = undefined; - this._profilerId = undefined; - this._isRunning = false; - this._sessionSampled = false; - this._lifecycleMode = undefined; - } - - /** Initialize the profiler with client, session sampling and (optionally) lifecycle mode for no-op warnings. */ - public initialize(client: Client, sessionSampled: boolean, lifecycleMode?: 'manual' | 'trace'): void { - this._client = client; - this._sessionSampled = sessionSampled; - this._lifecycleMode = lifecycleMode; - // One Profiler ID per profiling session (user session) - this._profilerId = uuid4(); - - DEBUG_BUILD && debug.log("[Profiling] Initializing profiler (lifecycle='manual')."); - } - - /** Start profiling if not already running. No-ops (with debug logs) when not sampled or in 'trace' mode. */ - public start(): void { - if (this._isRunning) { - DEBUG_BUILD && debug.log('[Profiling] Profile session already running, no-op.'); - return; - } - - if (!this._sessionSampled) { - DEBUG_BUILD && debug.log('[Profiling] Session not sampled, start() is a no-op.'); - return; - } - - if (this._lifecycleMode === 'trace') { - DEBUG_BUILD && - debug.log( - '[Profiling] `profileLifecycle` is set to \"trace\"; manual start/stop calls are ignored in trace mode.', - ); - return; - } - - this._isRunning = true; - // Match emitted chunks with events - getGlobalScope().setContext('profile', { profiler_id: this._profilerId }); - - this._startProfilerInstance(); - - if (!this._profiler) { - DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler in manual lifecycle. Stopping.'); - this._resetProfilerInfo(); - return; - } - - this._startPeriodicChunking(); - } - - /** Stop profiling; final chunk will be collected and sent. */ - public stop(): void { - if (!this._isRunning) { - DEBUG_BUILD && debug.log('[Profiling] No profile session running, stop() is a no-op.'); - return; - } - - if (this._lifecycleMode === 'trace') { - DEBUG_BUILD && - debug.log( - '[Profiling] `profileLifecycle` is set to \"trace\"; manual start/stop calls are ignored in trace mode.', - ); - return; - } - - this._isRunning = false; - if (this._chunkTimer) { - clearTimeout(this._chunkTimer); - this._chunkTimer = undefined; - } - - // Collect whatever was currently recording - this._collectCurrentChunk().catch(e => { - DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `stop()`:', e); - }); - - // Clear profiling context so subsequent events aren't marked as profiled - getGlobalScope().setContext('profile', {}); - } - - /** Resets profiling info and running state. */ - private _resetProfilerInfo(): void { - this._isRunning = false; - getGlobalScope().setContext('profile', {}); - } - - /** 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.'); - return; - } - this._profiler = profiler; - } - - /** Schedule periodic chunking while running. */ - 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); - } - - /** 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); - - 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); - }); - } -} From 9373335d96c27d948edd596626f3d711704d8b6d Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:00:41 +0100 Subject: [PATCH 13/18] add deprecation warning --- packages/core/src/types-hoist/browseroptions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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; From 038edda8b281468790ab6080602b8cacd4fba58f Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:31:33 +0100 Subject: [PATCH 14/18] remove ts comments --- packages/browser/src/profiling/integration.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index bd1ddf3019da..f0a445448f52 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -66,8 +66,6 @@ const _browserProfilingIntegration = (() => { // If there is an active, sampled root span already, notify the profiler if (rootSpan) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore accessing integration instance property this._profiler.notifyRootSpanActive(rootSpan); } @@ -76,8 +74,6 @@ const _browserProfilingIntegration = (() => { const laterActiveSpan = getActiveSpan(); const laterRootSpan = laterActiveSpan && getRootSpan(laterActiveSpan); if (laterRootSpan) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore accessing integration instance property this._profiler.notifyRootSpanActive(laterRootSpan); } }, 0); From 9fe4c7d7787d977b85129d4a310e408fac0b24ed Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:43:32 +0100 Subject: [PATCH 15/18] fix small issues --- .../suites/profiling/manualMode/test.ts | 6 +++--- packages/browser/src/profiling/UIProfiler.ts | 5 +---- packages/browser/test/profiling/UIProfiler.test.ts | 2 +- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts index 810326e66adb..2e4358563aa2 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts @@ -87,7 +87,7 @@ sentryTest('sends profile_chunk envelopes in manual mode', async ({ page, getLoc isChunkFormat: true, }); - // does not contain fibonacci3 (called during unprofiled part) - const functionNames2 = envelopeItemPayload1.profile.frames.map(frame => frame.function).filter(name => name !== ''); - expect(functionNames2).toEqual(expect.not.arrayContaining(['fibonacci3'])); + // 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/packages/browser/src/profiling/UIProfiler.ts b/packages/browser/src/profiling/UIProfiler.ts index 17e5055a4908..83cc1718d50f 100644 --- a/packages/browser/src/profiling/UIProfiler.ts +++ b/packages/browser/src/profiling/UIProfiler.ts @@ -115,7 +115,7 @@ export class UIProfiler implements ContinuousProfiler { } if (!this._isRunning) { - DEBUG_BUILD && debug.warn('[Profiling] Profile session is already running, `uiProfiler.stop()` is a no-op.'); + DEBUG_BUILD && debug.warn('[Profiling] Profiler is not running, `uiProfiler.stop()` is a no-op.'); return; } @@ -159,9 +159,6 @@ export class UIProfiler implements ContinuousProfiler { // Expose profiler_id to match root spans with profiles getGlobalScope().setContext('profile', { profiler_id: this._profilerId }); - // Expose profiler_id to match root spans with profiles - getGlobalScope().setContext('profile', { profiler_id: this._profilerId }); - this._startProfilerInstance(); if (!this._profiler) { diff --git a/packages/browser/test/profiling/UIProfiler.test.ts b/packages/browser/test/profiling/UIProfiler.test.ts index 5e48694fcc04..f3604ce7df41 100644 --- a/packages/browser/test/profiling/UIProfiler.test.ts +++ b/packages/browser/test/profiling/UIProfiler.test.ts @@ -708,7 +708,7 @@ describe('Browser Profiling v2 manual lifecycle', () => { expect(stop).toHaveBeenCalledTimes(1); expect(debugWarnSpy).toHaveBeenCalledWith( - '[Profiling] Profile session is already running, `uiProfiler.stop()` is a no-op.', + '[Profiling] Profiler is not running, `uiProfiler.stop()` is a no-op.', ); }); From 79dae58d4d7fdf5c5f91eeea61ce7219cab3aa7a Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:48:13 +0100 Subject: [PATCH 16/18] fix prettier --- packages/browser/test/profiling/UIProfiler.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/browser/test/profiling/UIProfiler.test.ts b/packages/browser/test/profiling/UIProfiler.test.ts index f3604ce7df41..6872e1e1beff 100644 --- a/packages/browser/test/profiling/UIProfiler.test.ts +++ b/packages/browser/test/profiling/UIProfiler.test.ts @@ -707,9 +707,7 @@ describe('Browser Profiling v2 manual lifecycle', () => { await Promise.resolve(); expect(stop).toHaveBeenCalledTimes(1); - expect(debugWarnSpy).toHaveBeenCalledWith( - '[Profiling] Profiler is not running, `uiProfiler.stop()` is a no-op.', - ); + expect(debugWarnSpy).toHaveBeenCalledWith('[Profiling] Profiler is not running, `uiProfiler.stop()` is a no-op.'); }); it('profileSessionSampleRate is required', async () => { From ea9857959c2d44fe077eac910b794999f5222f92 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:19:37 +0100 Subject: [PATCH 17/18] fix AI review comments --- packages/browser/src/profiling/UIProfiler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/browser/src/profiling/UIProfiler.ts b/packages/browser/src/profiling/UIProfiler.ts index 83cc1718d50f..fb059b836986 100644 --- a/packages/browser/src/profiling/UIProfiler.ts +++ b/packages/browser/src/profiling/UIProfiler.ts @@ -133,7 +133,7 @@ export class UIProfiler implements ContinuousProfiler { return; } - this._activeRootSpanIds.add(spanId); + this._registerTraceRootSpan(spanId); const rootSpanCount = this._activeRootSpanIds.size; @@ -190,7 +190,7 @@ export class UIProfiler implements ContinuousProfiler { }); // Manual: Clear profiling context so spans outside start()/stop() aren't marked as profiled - // Trace: Profile context is kept as long as there is an active root span + // Trace: Profile context is kept for the whole session duration if (this._lifecycleMode === 'manual') { getGlobalScope().setContext('profile', {}); } From ffc15f2b8b28aab5f32092cb213b37f48d38d91d Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:27:06 +0100 Subject: [PATCH 18/18] fix deprecation issues --- packages/browser/src/client.ts | 1 - packages/browser/src/profiling/integration.ts | 1 + packages/browser/src/profiling/utils.ts | 7 +++++-- 3 files changed, 6 insertions(+), 3 deletions(-) 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/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index f0a445448f52..08dd5b8a08dc 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -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; diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 1d5fc55e0752..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.) @@ -727,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'; }