@@ -9,28 +9,23 @@ import {
99 getSdkMetadataForEnvelopeHeader ,
1010 uuid4 ,
1111} from '@sentry/core' ;
12- import { DEBUG_BUILD } from '.. /../debug-build' ;
13- import type { JSSelfProfiler } from '.. /jsSelfProfiling' ;
14- import { createProfileChunkPayload , startJSSelfProfile , validateProfileChunk } from '.. /utils' ;
12+ import { DEBUG_BUILD } from './../debug-build' ;
13+ import type { JSSelfProfiler } from './jsSelfProfiling' ;
14+ import { createProfileChunkPayload , startJSSelfProfile , validateProfileChunk } from './utils' ;
1515
16- // Unified constants (kept identical to previous implementations)
1716const CHUNK_INTERVAL_MS = 60_000 ; // 1 minute
17+ // Maximum length for trace lifecycle profiling per root span (e.g. if spanEnd never fires)
1818const MAX_ROOT_SPAN_PROFILE_MS = 300_000 ; // 5 minutes max per root span in trace mode
1919
2020/**
21- * UIProfiler (Browser UI Profiling / Profiling V2)
21+ * UIProfiler (Profiling V2):
2222 * Supports two lifecycle modes:
2323 * - 'manual': controlled explicitly via start()/stop()
2424 * - 'trace': automatically runs while there are active sampled root spans
2525 *
26- * While running (either mode), we periodically stop and restart the JS self-profiling API
27- * to emit standalone `profile_chunk` envelopes every 60s and when profiling stops.
28- *
29- * Public API surface (used by integration and user-facing profiler hooks):
30- * - initialize(client, sessionSampled, lifecycleMode)
31- * - start()
32- * - stop()
33- * - notifyRootSpanActive(span) (only meaningful in 'trace' mode)
26+ * Profiles are emitted as standalone `profile_chunk` envelopes either when:
27+ * - there are no more sampled root spans, or
28+ * - the 60s chunk timer elapses while profiling is running.
3429 */
3530export class UIProfiler {
3631 private _client : Client | undefined ;
@@ -63,6 +58,7 @@ export class UIProfiler {
6358
6459 /** Initialize the profiler with client, session sampling and lifecycle mode. */
6560 public initialize ( client : Client , sessionSampled : boolean , lifecycleMode : 'manual' | 'trace' ) : void {
61+ // One Profiler ID per profiling session (user session)
6662 this . _profilerId = uuid4 ( ) ;
6763
6864 DEBUG_BUILD && debug . log ( `[Profiling] Initializing profiler (lifecycle='${ lifecycleMode } ').` ) ;
@@ -71,10 +67,6 @@ export class UIProfiler {
7167 this . _sessionSampled = sessionSampled ;
7268 this . _lifecycleMode = lifecycleMode ;
7369
74- if ( ! sessionSampled ) {
75- DEBUG_BUILD && debug . log ( '[Profiling] Session not sampled; profiler will remain inactive.' ) ;
76- }
77-
7870 if ( lifecycleMode === 'trace' ) {
7971 this . _setupTraceLifecycleListeners ( client ) ;
8072 }
@@ -118,16 +110,17 @@ export class UIProfiler {
118110 }
119111
120112 /** Handle an already-active root span at integration setup time (used only in trace mode). */
121- public notifyRootSpanActive ( span : Span ) : void {
113+ public notifyRootSpanActive ( rootSpan : Span ) : void {
122114 if ( this . _lifecycleMode !== 'trace' || ! this . _sessionSampled ) {
123115 return ;
124116 }
125117
126- const spanId = span . spanContext ( ) . spanId ;
118+ const spanId = rootSpan . spanContext ( ) . spanId ;
127119 if ( ! spanId || this . _activeRootSpanIds . has ( spanId ) ) {
128120 return ;
129121 }
130- this . _registerTraceRootSpan ( spanId ) ;
122+
123+ this . _activeRootSpanIds . add ( spanId ) ;
131124
132125 const rootSpanCount = this . _activeRootSpanIds . size ;
133126
@@ -176,8 +169,9 @@ export class UIProfiler {
176169
177170 this . _clearAllRootSpanTimeouts ( ) ;
178171
172+ // Collect whatever was currently recording
179173 this . _collectCurrentChunk ( ) . catch ( e => {
180- DEBUG_BUILD && debug . error ( '[Profiling] Failed to collect current profile chunk on stop():' , e ) ;
174+ DEBUG_BUILD && debug . error ( '[Profiling] Failed to collect current profile chunk on ` stop()` :' , e ) ;
181175 } ) ;
182176
183177 // Clear context so subsequent events aren't marked as profiled in manual mode.
@@ -204,7 +198,7 @@ export class UIProfiler {
204198 }
205199
206200 // Match emitted chunks with events: set profiler_id on global scope
207- // do I need this?
201+ // todo: do I need this?
208202 // getGlobalScope().setContext('profile', { profiler_id: this._profilerId });
209203
210204 const spanId = span . spanContext ( ) . spanId ;
@@ -254,9 +248,9 @@ export class UIProfiler {
254248 getGlobalScope ( ) . setContext ( 'profile' , { } ) ;
255249 }
256250
257- /** Clear all trace-mode root span timeouts. */
251+ /** Clear and reset all per- root- span timeouts. */
258252 private _clearAllRootSpanTimeouts ( ) : void {
259- this . _rootSpanTimeouts . forEach ( t => clearTimeout ( t ) ) ;
253+ this . _rootSpanTimeouts . forEach ( timeout => clearTimeout ( timeout ) ) ;
260254 this . _rootSpanTimeouts . clear ( ) ;
261255 }
262256
@@ -267,57 +261,69 @@ export class UIProfiler {
267261 this . _rootSpanTimeouts . set ( spanId , timeout ) ;
268262 }
269263
270- /** Start JS self profiler instance if needed. */
264+ /** Start a profiler instance if needed. */
271265 private _startProfilerInstance ( ) : void {
272266 if ( this . _profiler ?. stopped === false ) {
273267 return ; // already running
274268 }
275269 const profiler = startJSSelfProfile ( ) ;
276270 if ( ! profiler ) {
277- DEBUG_BUILD && debug . log ( '[Profiling] Failed to start JS self profiler .' ) ;
271+ DEBUG_BUILD && debug . log ( '[Profiling] Failed to start JS Profiler .' ) ;
278272 return ;
279273 }
280274 this . _profiler = profiler ;
281275 }
282276
283- /** Schedule periodic chunk collection while running. */
277+ /**
278+ * Schedule the next 60s chunk while running.
279+ * Each tick collects a chunk and restarts the profiler.
280+ * A chunk should be closed when there are no active root spans anymore OR when the maximum chunk interval is reached.
281+ */
284282 private _startPeriodicChunking ( ) : void {
285283 if ( ! this . _isRunning ) {
286284 return ;
287285 }
288286
289287 this . _chunkTimer = setTimeout ( ( ) => {
290288 this . _collectCurrentChunk ( ) . catch ( e => {
291- DEBUG_BUILD && debug . error ( '[Profiling] Failed to collect profile chunk during periodic chunking:' , e ) ;
289+ DEBUG_BUILD && debug . error ( '[Profiling] Failed to collect current profile chunk during periodic chunking:' , e ) ;
292290 } ) ;
293291
294292 if ( this . _isRunning ) {
295293 this . _startProfilerInstance ( ) ;
294+
296295 if ( ! this . _profiler ) {
297- // Could not restart -> stop profiling gracefully
296+ // If restart failed, stop scheduling further chunks and reset context.
298297 this . _resetProfilerInfo ( ) ;
299298 return ;
300299 }
300+
301301 this . _startPeriodicChunking ( ) ;
302302 }
303303 } , CHUNK_INTERVAL_MS ) ;
304304 }
305305
306- /** Root span timeout handler (trace mode). */
307- private _onRootSpanTimeout ( spanId : string ) : void {
308- if ( ! this . _rootSpanTimeouts . has ( spanId ) ) {
309- return ; // span already ended
306+ /**
307+ * Handle timeout for a specific root span ID to avoid indefinitely running profiler if `spanEnd` never fires.
308+ * If this was the last active root span, collect the current chunk and stop profiling.
309+ */
310+ private _onRootSpanTimeout ( rootSpanId : string ) : void {
311+ // If span already ended, ignore
312+ if ( ! this . _rootSpanTimeouts . has ( rootSpanId ) ) {
313+ return ;
310314 }
311- this . _rootSpanTimeouts . delete ( spanId ) ;
315+ this . _rootSpanTimeouts . delete ( rootSpanId ) ;
312316
313- if ( ! this . _activeRootSpanIds . has ( spanId ) ) {
317+ if ( ! this . _activeRootSpanIds . has ( rootSpanId ) ) {
314318 return ;
315319 }
316320
317321 DEBUG_BUILD &&
318- debug . log ( `[Profiling] Reached 5-minute timeout for root span ${ spanId } . Did you forget to call .end()?` ) ;
322+ debug . log (
323+ `[Profiling] Reached 5-minute timeout for root span ${ rootSpanId } . You likely started a manual root span that never called \`.end()\`.` ,
324+ ) ;
319325
320- this . _activeRootSpanIds . delete ( spanId ) ;
326+ this . _activeRootSpanIds . delete ( rootSpanId ) ;
321327
322328 if ( this . _activeRootSpanIds . size === 0 ) {
323329 this . _endProfiling ( ) ;
@@ -326,25 +332,32 @@ export class UIProfiler {
326332
327333 /** Stop current profiler instance, convert profile to chunk & send. */
328334 private async _collectCurrentChunk ( ) : Promise < void > {
329- const prev = this . _profiler ;
335+ const prevProfiler = this . _profiler ;
330336 this . _profiler = undefined ;
331- if ( ! prev ) {
337+
338+ if ( ! prevProfiler ) {
332339 return ;
333340 }
341+
334342 try {
335- const profile = await prev . stop ( ) ;
343+ const profile = await prevProfiler . stop ( ) ;
344+
336345 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
337346 const chunk = createProfileChunkPayload ( profile , this . _client ! , this . _profilerId ) ;
338- const validation = validateProfileChunk ( chunk ) ;
339- if ( 'reason' in validation ) {
347+
348+ // Validate chunk before sending
349+ const validationReturn = validateProfileChunk ( chunk ) ;
350+ if ( 'reason' in validationReturn ) {
340351 DEBUG_BUILD &&
341352 debug . log (
342353 '[Profiling] Discarding invalid profile chunk (this is probably a bug in the SDK):' ,
343- validation . reason ,
354+ validationReturn . reason ,
344355 ) ;
345356 return ;
346357 }
358+
347359 this . _sendProfileChunk ( chunk ) ;
360+
348361 DEBUG_BUILD && debug . log ( '[Profiling] Collected browser profile chunk.' ) ;
349362 } catch ( e ) {
350363 DEBUG_BUILD && debug . log ( '[Profiling] Error while stopping JS Profiler for chunk:' , e ) ;
@@ -355,6 +368,7 @@ export class UIProfiler {
355368 private _sendProfileChunk ( chunk : ProfileChunk ) : void {
356369 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
357370 const client = this . _client ! ;
371+
358372 const sdkInfo = getSdkMetadataForEnvelopeHeader ( client . getSdkMetadata ?.( ) ) ;
359373 const dsn = client . getDsn ( ) ;
360374 const tunnel = client . getOptions ( ) . tunnel ;
0 commit comments