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 1/4] 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 2/4] 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 3/4] 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 4/4] 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) {