1- import type { Span , StartSpanOptions } from '@sentry/core' ;
1+ /* eslint-disable max-lines */
2+ import type { Span , StartSpanOptions } from '@sentry/core' ;
23import { debug , fill , getActiveSpan , getSpanDescendants , SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN , SPAN_STATUS_ERROR , SPAN_STATUS_OK , spanToJSON , startInactiveSpan } from '@sentry/core' ;
34import * as React from 'react' ;
45import { useState } from 'react' ;
6+ import type { NativeFramesResponse } from '../NativeRNSentry' ;
7+ import { NATIVE } from '../wrapper' ;
58import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY , SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from './origin' ;
69import { getRNSentryOnDrawReporter } from './timetodisplaynative' ;
710import { setSpanDurationAsMeasurement , setSpanDurationAsMeasurementOnSpan } from './utils' ;
811
12+ /**
13+ * Timeout for fetching native frames
14+ */
15+ const FETCH_FRAMES_TIMEOUT_MS = 2_000 ;
16+
17+ /**
18+ * Maximum time to keep frame data in memory before cleaning up.
19+ * Prevents memory leaks for spans that never complete.
20+ */
21+ const FRAME_DATA_CLEANUP_TIMEOUT_MS = 60_000 ;
22+
923/**
1024 * Flags of active spans with manual initial display.
1125 */
@@ -16,6 +30,20 @@ export const manualInitialDisplaySpans = new WeakMap<Span, true>();
1630 */
1731const fullDisplayBeforeInitialDisplay = new WeakMap < Span , true > ( ) ;
1832
33+ interface FrameDataForSpan {
34+ startFrames : NativeFramesResponse | null ;
35+ endFrames : NativeFramesResponse | null ;
36+ cleanupTimeout ?: ReturnType < typeof setTimeout > ;
37+ }
38+
39+ /**
40+ * Stores frame data for in-flight TTID/TTFD spans.
41+ * Entries are automatically cleaned up when spans end (in captureEndFramesAndAttachToSpan finally block).
42+ * As a safety mechanism, entries are also cleaned up after FRAME_DATA_CLEANUP_TIMEOUT_MS
43+ * to prevent memory leaks for spans that never complete.
44+ */
45+ const spanFrameDataMap = new Map < string , FrameDataForSpan > ( ) ;
46+
1947export type TimeToDisplayProps = {
2048 children ?: React . ReactNode ;
2149 record ?: boolean ;
@@ -105,6 +133,10 @@ export function startTimeToInitialDisplaySpan(
105133 return undefined ;
106134 }
107135
136+ captureStartFramesForSpan ( initialDisplaySpan . spanContext ( ) . spanId ) . catch ( ( error ) => {
137+ debug . log ( `[TimeToDisplay] Failed to capture start frames for initial display span (${ initialDisplaySpan . spanContext ( ) . spanId } ).` , error ) ;
138+ } ) ;
139+
108140 if ( options ?. isAutoInstrumented ) {
109141 initialDisplaySpan . setAttribute ( SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN , SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY ) ;
110142 } else {
@@ -161,13 +193,26 @@ export function startTimeToFullDisplaySpan(
161193 return undefined ;
162194 }
163195
196+ captureStartFramesForSpan ( fullDisplaySpan . spanContext ( ) . spanId ) . catch ( ( error ) => {
197+ debug . log ( `[TimeToDisplay] Failed to capture start frames for full display span (${ fullDisplaySpan . spanContext ( ) . spanId } ).` , error ) ;
198+ } ) ;
199+
164200 const timeout = setTimeout ( ( ) => {
165201 if ( spanToJSON ( fullDisplaySpan ) . timestamp ) {
166202 return ;
167203 }
168204 fullDisplaySpan . setStatus ( { code : SPAN_STATUS_ERROR , message : 'deadline_exceeded' } ) ;
169- fullDisplaySpan . end ( spanToJSON ( initialDisplaySpan ) . timestamp ) ;
170- setSpanDurationAsMeasurement ( 'time_to_full_display' , fullDisplaySpan ) ;
205+
206+ captureEndFramesAndAttachToSpan ( fullDisplaySpan ) . then ( ( ) => {
207+ debug . log ( `[TimeToDisplay] span ${ fullDisplaySpan . spanContext ( ) . spanId } updated with frame data.` ) ;
208+ fullDisplaySpan . end ( spanToJSON ( initialDisplaySpan ) . timestamp ) ;
209+ setSpanDurationAsMeasurement ( 'time_to_full_display' , fullDisplaySpan ) ;
210+ } ) . catch ( ( ) => {
211+ debug . warn ( `[TimeToDisplay] Failed to capture end frames for full display span (${ fullDisplaySpan . spanContext ( ) . spanId } ).` ) ;
212+ fullDisplaySpan . end ( spanToJSON ( initialDisplaySpan ) . timestamp ) ;
213+ setSpanDurationAsMeasurement ( 'time_to_full_display' , fullDisplaySpan ) ;
214+ } ) ;
215+
171216 debug . warn ( '[TimeToDisplay] Full display span deadline_exceeded.' ) ;
172217 } , options . timeoutMs ) ;
173218
@@ -220,17 +265,31 @@ export function updateInitialDisplaySpan(
220265 return ;
221266 }
222267
223- span . end ( frameTimestampSeconds ) ;
224- span . setStatus ( { code : SPAN_STATUS_OK } ) ;
225- debug . log ( `[TimeToDisplay] ${ spanToJSON ( span ) . description } span updated with end timestamp.` ) ;
268+ captureEndFramesAndAttachToSpan ( span ) . then ( ( ) => {
269+ span . end ( frameTimestampSeconds ) ;
270+ span . setStatus ( { code : SPAN_STATUS_OK } ) ;
271+ debug . log ( `[TimeToDisplay] ${ spanToJSON ( span ) . description } span updated with end timestamp and frame data.` ) ;
272+
273+ if ( fullDisplayBeforeInitialDisplay . has ( activeSpan ) ) {
274+ fullDisplayBeforeInitialDisplay . delete ( activeSpan ) ;
275+ debug . log ( `[TimeToDisplay] Updating full display with initial display (${ span . spanContext ( ) . spanId } ) end.` ) ;
276+ updateFullDisplaySpan ( frameTimestampSeconds , span ) ;
277+ }
226278
227- if ( fullDisplayBeforeInitialDisplay . has ( activeSpan ) ) {
228- fullDisplayBeforeInitialDisplay . delete ( activeSpan ) ;
229- debug . log ( `[TimeToDisplay] Updating full display with initial display (${ span . spanContext ( ) . spanId } ) end.` ) ;
230- updateFullDisplaySpan ( frameTimestampSeconds , span ) ;
231- }
279+ setSpanDurationAsMeasurementOnSpan ( 'time_to_initial_display' , span , activeSpan ) ;
280+ } ) . catch ( ( error ) => {
281+ debug . log ( '[TimeToDisplay] Failed to capture frame data for initial display span.' , error ) ;
282+ span . end ( frameTimestampSeconds ) ;
283+ span . setStatus ( { code : SPAN_STATUS_OK } ) ;
284+
285+ if ( fullDisplayBeforeInitialDisplay . has ( activeSpan ) ) {
286+ fullDisplayBeforeInitialDisplay . delete ( activeSpan ) ;
287+ debug . log ( `[TimeToDisplay] Updating full display with initial display (${ span . spanContext ( ) . spanId } ) end.` ) ;
288+ updateFullDisplaySpan ( frameTimestampSeconds , span ) ;
289+ }
232290
233- setSpanDurationAsMeasurementOnSpan ( 'time_to_initial_display' , span , activeSpan ) ;
291+ setSpanDurationAsMeasurementOnSpan ( 'time_to_initial_display' , span , activeSpan ) ;
292+ } ) ;
234293}
235294
236295function updateFullDisplaySpan ( frameTimestampSeconds : number , passedInitialDisplaySpan ?: Span ) : void {
@@ -263,17 +322,26 @@ function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDispl
263322 return ;
264323 }
265324
266- if ( initialDisplayEndTimestamp > frameTimestampSeconds ) {
267- debug . warn ( '[TimeToDisplay] Using initial display end. Full display end frame timestamp is before initial display end.' ) ;
268- span . end ( initialDisplayEndTimestamp ) ;
269- } else {
270- span . end ( frameTimestampSeconds ) ;
271- }
325+ captureEndFramesAndAttachToSpan ( span ) . then ( ( ) => {
326+ const endTimestamp = initialDisplayEndTimestamp > frameTimestampSeconds ? initialDisplayEndTimestamp : frameTimestampSeconds ;
272327
273- span . setStatus ( { code : SPAN_STATUS_OK } ) ;
274- debug . log ( `[TimeToDisplay] ${ spanJSON . description } (${ spanJSON . span_id } ) span updated with end timestamp.` ) ;
328+ if ( initialDisplayEndTimestamp > frameTimestampSeconds ) {
329+ debug . warn ( '[TimeToDisplay] Using initial display end. Full display end frame timestamp is before initial display end.' ) ;
330+ }
331+
332+ span . end ( endTimestamp ) ;
333+ span . setStatus ( { code : SPAN_STATUS_OK } ) ;
334+ debug . log ( `[TimeToDisplay] span ${ spanJSON . description } (${ spanJSON . span_id } ) updated with end timestamp and frame data.` ) ;
335+
336+ setSpanDurationAsMeasurement ( 'time_to_full_display' , span ) ;
337+ } ) . catch ( ( error ) => {
338+ debug . log ( '[TimeToDisplay] Failed to capture frame data for full display span.' , error ) ;
339+ const endTimestamp = initialDisplayEndTimestamp > frameTimestampSeconds ? initialDisplayEndTimestamp : frameTimestampSeconds ;
275340
276- setSpanDurationAsMeasurement ( 'time_to_full_display' , span ) ;
341+ span . end ( endTimestamp ) ;
342+ span . setStatus ( { code : SPAN_STATUS_OK } ) ;
343+ setSpanDurationAsMeasurement ( 'time_to_full_display' , span ) ;
344+ } ) ;
277345}
278346
279347/**
@@ -327,3 +395,143 @@ function createTimeToDisplay({
327395 TimeToDisplayWrapper . displayName = 'TimeToDisplayWrapper' ;
328396 return TimeToDisplayWrapper ;
329397}
398+
399+ /**
400+ * Attaches frame data to a span's data object.
401+ */
402+ function attachFrameDataToSpan ( span : Span , startFrames : NativeFramesResponse , endFrames : NativeFramesResponse ) : void {
403+ const totalFrames = endFrames . totalFrames - startFrames . totalFrames ;
404+ const slowFrames = endFrames . slowFrames - startFrames . slowFrames ;
405+ const frozenFrames = endFrames . frozenFrames - startFrames . frozenFrames ;
406+
407+ if ( totalFrames <= 0 && slowFrames <= 0 && frozenFrames <= 0 ) {
408+ debug . warn ( `[TimeToDisplay] Detected zero slow or frozen frames. Not adding measurements to span (${ span . spanContext ( ) . spanId } ).` ) ;
409+ return ;
410+ }
411+ span . setAttribute ( 'frames.total' , totalFrames ) ;
412+ span . setAttribute ( 'frames.slow' , slowFrames ) ;
413+ span . setAttribute ( 'frames.frozen' , frozenFrames ) ;
414+
415+ debug . log ( '[TimeToDisplay] Attached frame data to span.' , {
416+ spanId : span . spanContext ( ) . spanId ,
417+ frameData : {
418+ total : totalFrames ,
419+ slow : slowFrames ,
420+ frozen : frozenFrames ,
421+ } ,
422+ } ) ;
423+ }
424+
425+ /**
426+ * Captures start frames for a time-to-display span
427+ */
428+ async function captureStartFramesForSpan ( spanId : string ) : Promise < void > {
429+ if ( ! NATIVE . enableNative ) {
430+ return ;
431+ }
432+
433+ try {
434+ const startFrames = await fetchNativeFramesWithTimeout ( ) ;
435+
436+ // Set up automatic cleanup as a safety mechanism for spans that never complete
437+ const cleanupTimeout = setTimeout ( ( ) => {
438+ const entry = spanFrameDataMap . get ( spanId ) ;
439+ if ( entry ) {
440+ spanFrameDataMap . delete ( spanId ) ;
441+ debug . log ( `[TimeToDisplay] Cleaned up stale frame data for span ${ spanId } after timeout.` ) ;
442+ }
443+ } , FRAME_DATA_CLEANUP_TIMEOUT_MS ) ;
444+
445+ if ( ! spanFrameDataMap . has ( spanId ) ) {
446+ spanFrameDataMap . set ( spanId , { startFrames : null , endFrames : null , cleanupTimeout } ) ;
447+ }
448+
449+ // Re-check after async operations - entry might have been deleted by captureEndFramesAndAttachToSpan
450+ const frameData = spanFrameDataMap . get ( spanId ) ;
451+ if ( ! frameData ) {
452+ // Span already ended and cleaned up, cancel the cleanup timeout
453+ clearTimeout ( cleanupTimeout ) ;
454+ debug . log ( `[TimeToDisplay] Span ${ spanId } already ended, discarding start frames.` ) ;
455+ return ;
456+ }
457+
458+ frameData . startFrames = startFrames ;
459+ frameData . cleanupTimeout = cleanupTimeout ;
460+ debug . log ( `[TimeToDisplay] Captured start frames for span ${ spanId } .` , startFrames ) ;
461+ } catch ( error ) {
462+ debug . log ( `[TimeToDisplay] Failed to capture start frames for span ${ spanId } .` , error ) ;
463+ }
464+ }
465+
466+ /**
467+ * Captures end frames and attaches frame data to span
468+ */
469+ async function captureEndFramesAndAttachToSpan ( span : Span ) : Promise < void > {
470+ if ( ! NATIVE . enableNative ) {
471+ return ;
472+ }
473+
474+ const spanId = span . spanContext ( ) . spanId ;
475+ const frameData = spanFrameDataMap . get ( spanId ) ;
476+
477+ if ( ! frameData ?. startFrames ) {
478+ debug . log ( `[TimeToDisplay] No start frames found for span ${ spanId } , skipping frame data collection.` ) ;
479+ return ;
480+ }
481+
482+ try {
483+ const endFrames = await fetchNativeFramesWithTimeout ( ) ;
484+ frameData . endFrames = endFrames ;
485+
486+ attachFrameDataToSpan ( span , frameData . startFrames , endFrames ) ;
487+
488+ debug . log ( `[TimeToDisplay] Captured and attached end frames for span ${ spanId } .` , endFrames ) ;
489+ } catch ( error ) {
490+ debug . log ( `[TimeToDisplay] Failed to capture end frames for span ${ spanId } .` , error ) ;
491+ } finally {
492+ // Clear the cleanup timeout since we're cleaning up now
493+ if ( frameData . cleanupTimeout ) {
494+ clearTimeout ( frameData . cleanupTimeout ) ;
495+ }
496+ spanFrameDataMap . delete ( spanId ) ;
497+ }
498+ }
499+
500+ /**
501+ * Fetches native frames with a timeout
502+ */
503+ function fetchNativeFramesWithTimeout ( ) : Promise < NativeFramesResponse > {
504+ return new Promise < NativeFramesResponse > ( ( resolve , reject ) => {
505+ let settled = false ;
506+
507+ const timeoutId = setTimeout ( ( ) => {
508+ if ( ! settled ) {
509+ settled = true ;
510+ reject ( 'Fetching native frames took too long. Dropping frames.' ) ;
511+ }
512+ } , FETCH_FRAMES_TIMEOUT_MS ) ;
513+
514+ NATIVE . fetchNativeFrames ( )
515+ . then ( value => {
516+ if ( settled ) {
517+ return ;
518+ }
519+ clearTimeout ( timeoutId ) ;
520+ settled = true ;
521+
522+ if ( ! value ) {
523+ reject ( 'Native frames response is null.' ) ;
524+ return ;
525+ }
526+ resolve ( value ) ;
527+ } )
528+ . then ( undefined , ( error : unknown ) => {
529+ if ( settled ) {
530+ return ;
531+ }
532+ clearTimeout ( timeoutId ) ;
533+ settled = true ;
534+ reject ( error ) ;
535+ } ) ;
536+ } ) ;
537+ }
0 commit comments