From 9adc127c6fd84b4978a209cffd034bd8a9df17bb Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 8 Jul 2026 12:43:19 +0200 Subject: [PATCH 01/11] feat(tracing): Add `reportFullyDisplayed()` static API Add imperative `Sentry.reportFullyDisplayed()` API for signaling Time to Full Display, matching the cross-SDK spec at develop.sentry.dev. This is the imperative equivalent of the `` component. Co-Authored-By: Claude Opus 4.6 --- packages/core/etc/sentry-react-native.api.md | 3 + packages/core/src/js/index.ts | 1 + .../core/src/js/tracing/timetodisplay.tsx | 40 ++++- .../core/test/tracing/timetodisplay.test.tsx | 159 ++++++++++++++++++ 4 files changed, 194 insertions(+), 9 deletions(-) diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index 2d9066c23e..2e80b05213 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -633,6 +633,9 @@ export const reactNavigationIntegration: (input?: Partial(); /** - * Flag full display called before initial display for an active span. + * Stores deferred full display state when reportFullyDisplayed / updateFullDisplaySpan + * is called before TTID completes. Preserves the isAutoInstrumented flag so the + * correct span origin is applied when the deferred call fires. */ -const fullDisplayBeforeInitialDisplay = new WeakMap(); +const fullDisplayBeforeInitialDisplay = new WeakMap(); interface FrameDataForSpan { startFrames: NativeFramesResponse | null; @@ -391,10 +393,11 @@ export function updateInitialDisplaySpan( span.setStatus({ code: SPAN_STATUS_OK }); debug.log(`[TimeToDisplay] ${spanToJSON(span).description} span updated with end timestamp and frame data.`); - if (fullDisplayBeforeInitialDisplay.has(activeSpan)) { + const deferred = fullDisplayBeforeInitialDisplay.get(activeSpan); + if (deferred) { fullDisplayBeforeInitialDisplay.delete(activeSpan); debug.log(`[TimeToDisplay] Updating full display with initial display (${span.spanContext().spanId}) end.`); - updateFullDisplaySpan(frameTimestampSeconds, span); + updateFullDisplaySpan(frameTimestampSeconds, span, deferred.isAutoInstrumented); } setSpanDurationAsMeasurementOnSpan('time_to_initial_display', span, activeSpan); @@ -404,17 +407,36 @@ export function updateInitialDisplaySpan( span.end(frameTimestampSeconds); span.setStatus({ code: SPAN_STATUS_OK }); - if (fullDisplayBeforeInitialDisplay.has(activeSpan)) { + const deferred = fullDisplayBeforeInitialDisplay.get(activeSpan); + if (deferred) { fullDisplayBeforeInitialDisplay.delete(activeSpan); debug.log(`[TimeToDisplay] Updating full display with initial display (${span.spanContext().spanId}) end.`); - updateFullDisplaySpan(frameTimestampSeconds, span); + updateFullDisplaySpan(frameTimestampSeconds, span, deferred.isAutoInstrumented); } setSpanDurationAsMeasurementOnSpan('time_to_initial_display', span, activeSpan); }); } -function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDisplaySpan?: Span): void { +/** + * Reports that the screen is fully displayed. + * + * Ends the TTFD span (`ui.load.full_display`) with the current timestamp. + * If called before TTID completes, the TTFD span is deferred until TTID ends. + * Subsequent calls are ignored once the span has ended. + * + * This is the imperative equivalent of the `` component, + * matching the cross-SDK `Sentry.reportFullyDisplayed()` API. + */ +export function reportFullyDisplayed(): void { + updateFullDisplaySpan(Date.now() / 1000, undefined, false); +} + +function updateFullDisplaySpan( + frameTimestampSeconds: number, + passedInitialDisplaySpan?: Span, + isAutoInstrumented: boolean = true, +): void { const activeSpan = getActiveSpan(); if (!activeSpan) { debug.warn('[TimeToDisplay] No active span found to update ui.load.full_display in.'); @@ -426,7 +448,7 @@ function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDispl getSpanDescendants(activeSpan).find(span => spanToJSON(span).op === 'ui.load.initial_display'); const initialDisplayEndTimestamp = existingInitialDisplaySpan && spanToJSON(existingInitialDisplaySpan).timestamp; if (!initialDisplayEndTimestamp) { - fullDisplayBeforeInitialDisplay.set(activeSpan, true); + fullDisplayBeforeInitialDisplay.set(activeSpan, { isAutoInstrumented }); debug.warn( `[TimeToDisplay] Full display called before initial display for active span (${activeSpan.spanContext().spanId}).`, ); @@ -434,7 +456,7 @@ function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDispl } const span = startTimeToFullDisplaySpan({ - isAutoInstrumented: true, + isAutoInstrumented, }); if (!span) { debug.warn('[TimeToDisplay] No TimeToFullDisplay span found or created, possibly performance is disabled.'); diff --git a/packages/core/test/tracing/timetodisplay.test.tsx b/packages/core/test/tracing/timetodisplay.test.tsx index 991aa9043e..594366647f 100644 --- a/packages/core/test/tracing/timetodisplay.test.tsx +++ b/packages/core/test/tracing/timetodisplay.test.tsx @@ -31,6 +31,7 @@ import { } from '../../src/js/tracing/semanticAttributes'; import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span'; import { + reportFullyDisplayed, startTimeToFullDisplaySpan, startTimeToInitialDisplaySpan, TimeToFullDisplay, @@ -990,3 +991,161 @@ describe('Frame Data', () => { }); }); }); + +describe('reportFullyDisplayed', () => { + let client: TestClient; + + beforeEach(() => { + clearMockedOnDrawReportedProps(); + _resetTimeToDisplayCoordinator(); + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + const options = getDefaultTestClientOptions({ + tracesSampleRate: 1.0, + }); + client = new TestClient({ + ...options, + integrations: [...options.integrations, timeToDisplayIntegration()], + }); + setCurrentClient(client); + client.init(); + }); + + afterEach(() => { + jest.clearAllMocks(); + mockWrapper.NATIVE.enableNative = true; + }); + + test('creates full display span with manual origin and measurement', async () => { + await startSpanManual( + { + name: 'Root Manual Span', + startTime: secondAgoTimestampMs(), + }, + async (activeSpan: Span | undefined) => { + const ttidSpan = startTimeToInitialDisplaySpan(); + render(); + + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + updateInitialDisplaySpan(nowInSeconds(), { activeSpan, span: ttidSpan }); + + for (let i = 0; i < 15; i++) { + await Promise.resolve(); + } + + reportFullyDisplayed(); + + for (let i = 0; i < 15; i++) { + await Promise.resolve(); + } + + activeSpan?.end(); + }, + ); + + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + const ttfdSpan = getFullDisplaySpanJSON(client.event!.spans!); + expect(ttfdSpan).toBeDefined(); + expect(ttfdSpan!.op).toBe('ui.load.full_display'); + expect(ttfdSpan!.status).toBe('ok'); + expect(ttfdSpan!.timestamp).toBeDefined(); + expect(ttfdSpan!.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe(SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY); + expectFullDisplayMeasurementOnSpan(client.event!); + }); + + test('defers full display when called before initial display with correct origin', async () => { + await startSpanManual( + { + name: 'Root Manual Span', + startTime: secondAgoTimestampMs(), + }, + async (activeSpan: Span | undefined) => { + const ttidSpan = startTimeToInitialDisplaySpan(); + render(); + + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + reportFullyDisplayed(); + + updateInitialDisplaySpan(nowInSeconds(), { activeSpan, span: ttidSpan }); + + for (let i = 0; i < 15; i++) { + await Promise.resolve(); + } + + activeSpan?.end(); + }, + ); + + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + const ttfdSpan = getFullDisplaySpanJSON(client.event!.spans!); + expect(ttfdSpan).toBeDefined(); + expect(ttfdSpan!.op).toBe('ui.load.full_display'); + expect(ttfdSpan!.status).toBe('ok'); + expect(ttfdSpan!.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe(SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY); + + const ttidSpanJSON = getInitialDisplaySpanJSON(client.event!.spans!); + expect(ttfdSpan!.timestamp).toEqual(ttidSpanJSON!.timestamp); + + expectFullDisplayMeasurementOnSpan(client.event!); + }); + + test('does nothing without an active span', () => { + expect(() => reportFullyDisplayed()).not.toThrow(); + expect(debug.warn).toHaveBeenCalledWith(expect.stringContaining('No active span found')); + }); + + test('second call is ignored after span already ended', async () => { + await startSpanManual( + { + name: 'Root Manual Span', + startTime: secondAgoTimestampMs(), + }, + async (activeSpan: Span | undefined) => { + const ttidSpan = startTimeToInitialDisplaySpan(); + render(); + + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + updateInitialDisplaySpan(nowInSeconds(), { activeSpan, span: ttidSpan }); + + for (let i = 0; i < 15; i++) { + await Promise.resolve(); + } + + reportFullyDisplayed(); + + for (let i = 0; i < 15; i++) { + await Promise.resolve(); + } + + reportFullyDisplayed(); + + for (let i = 0; i < 15; i++) { + await Promise.resolve(); + } + + activeSpan?.end(); + }, + ); + + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + const ttfdSpans = client.event!.spans!.filter((s: SpanJSON) => s.op === 'ui.load.full_display'); + expect(ttfdSpans).toHaveLength(1); + }); +}); From 51e2bb016d92c0f00db42d3931a46e0b9d972f27 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 8 Jul 2026 12:47:52 +0200 Subject: [PATCH 02/11] docs: Add changelog entry for reportFullyDisplayed Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6399d0b564..9f39abc583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Features - Add experimental `extendAppStart`/`finishExtendedAppStart`/`getExtendedAppStartSpan` to extend the standalone app start window and instrument post-init work ([#6392](https://github.com/getsentry/sentry-react-native/pull/6392)) +- Add `Sentry.reportFullyDisplayed()` imperative API for signaling Time to Full Display ([#6419](https://github.com/getsentry/sentry-react-native/pull/6419)) ### Changes From ca7eefc8f9bf393be7c2a5a6e453eeb19e6e60a9 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 8 Jul 2026 12:58:26 +0200 Subject: [PATCH 03/11] fix(tracing): Route reportFullyDisplayed through the integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store the timestamp in a JS-side map and let the timeToDisplayIntegration pick it up during processEvent, instead of calling updateFullDisplaySpan directly. The previous approach failed in production because it relied on updateInitialDisplaySpan to drain the deferred queue, but that function is never called in the real component/navigation flow — TTID timestamps are read from native during processEvent. Co-Authored-By: Claude Opus 4.6 --- .../integrations/timeToDisplayIntegration.ts | 4 +- .../core/src/js/tracing/timetodisplay.tsx | 63 +++++++---- .../core/test/tracing/timetodisplay.test.tsx | 105 +++++++++--------- 3 files changed, 96 insertions(+), 76 deletions(-) diff --git a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts index eaf331ab29..949478aced 100644 --- a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts +++ b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts @@ -8,6 +8,7 @@ import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISP import { getReactNavigationIntegration } from '../reactnavigation'; import { SEMANTIC_ATTRIBUTE_ROUTE_HAS_BEEN_SEEN } from '../semanticAttributes'; import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../span'; +import { _popImperativeTtfdTimestamp } from '../timetodisplay'; import { clearSpan as clearTimeToDisplayCoordinatorSpan } from '../timeToDisplayCoordinator'; import { getTimeToInitialDisplayFallback } from '../timeToDisplayFallback'; import { createSpanJSON } from '../utils'; @@ -224,7 +225,8 @@ async function addTimeToFullDisplay({ transactionStartTimestampSeconds: number; ttidSpan: SpanJSON | undefined; }): Promise { - const ttfdEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`); + const ttfdEndTimestampSeconds = + (await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`)) ?? _popImperativeTtfdTimestamp(rootSpanId); if (!ttidSpan || !ttfdEndTimestampSeconds) { return undefined; diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index a95ff43f0b..ca885c4585 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -41,11 +41,9 @@ const FRAME_DATA_CLEANUP_TIMEOUT_MS = 60_000; export const manualInitialDisplaySpans = new WeakMap(); /** - * Stores deferred full display state when reportFullyDisplayed / updateFullDisplaySpan - * is called before TTID completes. Preserves the isAutoInstrumented flag so the - * correct span origin is applied when the deferred call fires. + * Flag full display called before initial display for an active span. */ -const fullDisplayBeforeInitialDisplay = new WeakMap(); +const fullDisplayBeforeInitialDisplay = new WeakMap(); interface FrameDataForSpan { startFrames: NativeFramesResponse | null; @@ -393,11 +391,10 @@ export function updateInitialDisplaySpan( span.setStatus({ code: SPAN_STATUS_OK }); debug.log(`[TimeToDisplay] ${spanToJSON(span).description} span updated with end timestamp and frame data.`); - const deferred = fullDisplayBeforeInitialDisplay.get(activeSpan); - if (deferred) { + if (fullDisplayBeforeInitialDisplay.has(activeSpan)) { fullDisplayBeforeInitialDisplay.delete(activeSpan); debug.log(`[TimeToDisplay] Updating full display with initial display (${span.spanContext().spanId}) end.`); - updateFullDisplaySpan(frameTimestampSeconds, span, deferred.isAutoInstrumented); + updateFullDisplaySpan(frameTimestampSeconds, span); } setSpanDurationAsMeasurementOnSpan('time_to_initial_display', span, activeSpan); @@ -407,36 +404,60 @@ export function updateInitialDisplaySpan( span.end(frameTimestampSeconds); span.setStatus({ code: SPAN_STATUS_OK }); - const deferred = fullDisplayBeforeInitialDisplay.get(activeSpan); - if (deferred) { + if (fullDisplayBeforeInitialDisplay.has(activeSpan)) { fullDisplayBeforeInitialDisplay.delete(activeSpan); debug.log(`[TimeToDisplay] Updating full display with initial display (${span.spanContext().spanId}) end.`); - updateFullDisplaySpan(frameTimestampSeconds, span, deferred.isAutoInstrumented); + updateFullDisplaySpan(frameTimestampSeconds, span); } setSpanDurationAsMeasurementOnSpan('time_to_initial_display', span, activeSpan); }); } +/** + * Timestamps stored by the imperative `reportFullyDisplayed()` API. + * Keyed by the active span's span_id at call time. + * Consumed by `timeToDisplayIntegration.processEvent`. + */ +const _imperativeTtfdTimestamps = new Map(); + /** * Reports that the screen is fully displayed. * - * Ends the TTFD span (`ui.load.full_display`) with the current timestamp. - * If called before TTID completes, the TTFD span is deferred until TTID ends. - * Subsequent calls are ignored once the span has ended. + * Stores the current timestamp so the Time to Display integration + * can create the TTFD span (`ui.load.full_display`) during event processing. + * If called before TTID completes, the integration adjusts the TTFD + * timestamp to the TTID end (per the cross-SDK spec). + * Subsequent calls for the same span are ignored. * * This is the imperative equivalent of the `` component, * matching the cross-SDK `Sentry.reportFullyDisplayed()` API. */ export function reportFullyDisplayed(): void { - updateFullDisplaySpan(Date.now() / 1000, undefined, false); + const activeSpan = getActiveSpan(); + if (!activeSpan) { + debug.warn('[TimeToDisplay] No active span found to report full display.'); + return; + } + const spanId = spanToJSON(activeSpan).span_id; + if (spanId && !_imperativeTtfdTimestamps.has(spanId)) { + _imperativeTtfdTimestamps.set(spanId, Date.now() / 1000); + } } -function updateFullDisplaySpan( - frameTimestampSeconds: number, - passedInitialDisplaySpan?: Span, - isAutoInstrumented: boolean = true, -): void { +export function _popImperativeTtfdTimestamp(spanId: string): number | undefined { + const ts = _imperativeTtfdTimestamps.get(spanId); + if (ts !== undefined) { + _imperativeTtfdTimestamps.delete(spanId); + } + return ts; +} + +export function _resetImperativeTtfdTimestamps(): void { + _imperativeTtfdTimestamps.clear(); +} + +function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDisplaySpan?: Span): void { const activeSpan = getActiveSpan(); if (!activeSpan) { debug.warn('[TimeToDisplay] No active span found to update ui.load.full_display in.'); @@ -448,7 +469,7 @@ function updateFullDisplaySpan( getSpanDescendants(activeSpan).find(span => spanToJSON(span).op === 'ui.load.initial_display'); const initialDisplayEndTimestamp = existingInitialDisplaySpan && spanToJSON(existingInitialDisplaySpan).timestamp; if (!initialDisplayEndTimestamp) { - fullDisplayBeforeInitialDisplay.set(activeSpan, { isAutoInstrumented }); + fullDisplayBeforeInitialDisplay.set(activeSpan, true); debug.warn( `[TimeToDisplay] Full display called before initial display for active span (${activeSpan.spanContext().spanId}).`, ); @@ -456,7 +477,7 @@ function updateFullDisplaySpan( } const span = startTimeToFullDisplaySpan({ - isAutoInstrumented, + isAutoInstrumented: true, }); if (!span) { debug.warn('[TimeToDisplay] No TimeToFullDisplay span found or created, possibly performance is disabled.'); diff --git a/packages/core/test/tracing/timetodisplay.test.tsx b/packages/core/test/tracing/timetodisplay.test.tsx index 594366647f..04f616ce62 100644 --- a/packages/core/test/tracing/timetodisplay.test.tsx +++ b/packages/core/test/tracing/timetodisplay.test.tsx @@ -31,6 +31,7 @@ import { } from '../../src/js/tracing/semanticAttributes'; import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span'; import { + _resetImperativeTtfdTimestamps, reportFullyDisplayed, startTimeToFullDisplaySpan, startTimeToInitialDisplaySpan, @@ -998,6 +999,7 @@ describe('reportFullyDisplayed', () => { beforeEach(() => { clearMockedOnDrawReportedProps(); _resetTimeToDisplayCoordinator(); + _resetImperativeTtfdTimestamps(); getCurrentScope().clear(); getIsolationScope().clear(); getGlobalScope().clear(); @@ -1018,32 +1020,25 @@ describe('reportFullyDisplayed', () => { mockWrapper.NATIVE.enableNative = true; }); - test('creates full display span with manual origin and measurement', async () => { - await startSpanManual( + test('creates full display span with measurement via integration', async () => { + const ttidTimestamp = nowInSeconds(); + + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), }, - async (activeSpan: Span | undefined) => { - const ttidSpan = startTimeToInitialDisplaySpan(); + (activeSpan: Span | undefined) => { render(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - - updateInitialDisplaySpan(nowInSeconds(), { activeSpan, span: ttidSpan }); - - for (let i = 0; i < 15; i++) { - await Promise.resolve(); - } + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan).span_id]: ttidTimestamp, + }, + }); reportFullyDisplayed(); - for (let i = 0; i < 15; i++) { - await Promise.resolve(); - } - activeSpan?.end(); }, ); @@ -1051,36 +1046,35 @@ describe('reportFullyDisplayed', () => { await jest.runOnlyPendingTimersAsync(); await client.flush(); + expectFinishedInitialDisplaySpan(client.event!); + expectInitialDisplayMeasurementOnSpan(client.event!); + const ttfdSpan = getFullDisplaySpanJSON(client.event!.spans!); expect(ttfdSpan).toBeDefined(); expect(ttfdSpan!.op).toBe('ui.load.full_display'); expect(ttfdSpan!.status).toBe('ok'); expect(ttfdSpan!.timestamp).toBeDefined(); - expect(ttfdSpan!.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe(SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY); expectFullDisplayMeasurementOnSpan(client.event!); }); - test('defers full display when called before initial display with correct origin', async () => { - await startSpanManual( + test('adjusts ttfd to ttid end when reported before ttid', async () => { + const ttidTimestamp = secondInFutureTimestampMs() / 1000; + + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), }, - async (activeSpan: Span | undefined) => { - const ttidSpan = startTimeToInitialDisplaySpan(); + (activeSpan: Span | undefined) => { render(); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - reportFullyDisplayed(); - updateInitialDisplaySpan(nowInSeconds(), { activeSpan, span: ttidSpan }); - - for (let i = 0; i < 15; i++) { - await Promise.resolve(); - } + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan).span_id]: ttidTimestamp, + }, + }); activeSpan?.end(); }, @@ -1091,9 +1085,7 @@ describe('reportFullyDisplayed', () => { const ttfdSpan = getFullDisplaySpanJSON(client.event!.spans!); expect(ttfdSpan).toBeDefined(); - expect(ttfdSpan!.op).toBe('ui.load.full_display'); expect(ttfdSpan!.status).toBe('ok'); - expect(ttfdSpan!.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe(SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY); const ttidSpanJSON = getInitialDisplaySpanJSON(client.event!.spans!); expect(ttfdSpan!.timestamp).toEqual(ttidSpanJSON!.timestamp); @@ -1106,37 +1098,42 @@ describe('reportFullyDisplayed', () => { expect(debug.warn).toHaveBeenCalledWith(expect.stringContaining('No active span found')); }); - test('second call is ignored after span already ended', async () => { - await startSpanManual( + test('does not create ttfd span without ttid', async () => { + startSpanManual( { name: 'Root Manual Span', startTime: secondAgoTimestampMs(), }, - async (activeSpan: Span | undefined) => { - const ttidSpan = startTimeToInitialDisplaySpan(); - render(); - - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); + (activeSpan: Span | undefined) => { + reportFullyDisplayed(); + activeSpan?.end(); + }, + ); - updateInitialDisplaySpan(nowInSeconds(), { activeSpan, span: ttidSpan }); + await jest.runOnlyPendingTimersAsync(); + await client.flush(); - for (let i = 0; i < 15; i++) { - await Promise.resolve(); - } + expectNoFullDisplayMeasurementOnSpan(client.event!); + expect(getFullDisplaySpanJSON(client.event!.spans!)).toBeUndefined(); + }); - reportFullyDisplayed(); + test('second call is ignored', async () => { + startSpanManual( + { + name: 'Root Manual Span', + startTime: secondAgoTimestampMs(), + }, + (activeSpan: Span | undefined) => { + render(); - for (let i = 0; i < 15; i++) { - await Promise.resolve(); - } + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan).span_id]: nowInSeconds(), + }, + }); reportFullyDisplayed(); - - for (let i = 0; i < 15; i++) { - await Promise.resolve(); - } + reportFullyDisplayed(); activeSpan?.end(); }, From 3df6c1730fc35bfb2f41601a23f40a6dc2b52756 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 8 Jul 2026 13:16:14 +0200 Subject: [PATCH 04/11] fix(tracing): Use root span ID for imperative TTFD and guard duplicate spans - Use `getRootSpan` in `reportFullyDisplayed()` so the stored key matches the integration's `rootSpanId` lookup, even when called inside a nested child span. - Add early return in `addTimeToFullDisplay` when an existing ok TTFD span is found, preventing duplicate `ui.load.full_display` spans. Co-Authored-By: Claude Opus 4.6 --- .../integrations/timeToDisplayIntegration.ts | 5 +++ .../core/src/js/tracing/timetodisplay.tsx | 8 +++-- .../core/test/tracing/timetodisplay.test.tsx | 36 +++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts index 949478aced..4bbb7788d4 100644 --- a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts +++ b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts @@ -236,6 +236,11 @@ async function addTimeToFullDisplay({ let ttfdSpan = event.spans?.find(span => span.op === UI_LOAD_FULL_DISPLAY); + if (ttfdSpan && (ttfdSpan.status === undefined || ttfdSpan.status === 'ok')) { + debug.log(`[${INTEGRATION_NAME}] Ttfd span already exists and is ok.`, ttfdSpan); + return ttfdSpan; + } + let ttfdAdjustedEndTimestampSeconds = ttfdEndTimestampSeconds; const ttfdIsBeforeTtid = ttidSpan.timestamp && ttfdEndTimestampSeconds < ttidSpan.timestamp; if (ttfdIsBeforeTtid && ttidSpan.timestamp) { diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index ca885c4585..1dfc46f3d4 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -5,6 +5,7 @@ import { debug, fill, getActiveSpan, + getRootSpan, getSpanDescendants, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, @@ -439,9 +440,10 @@ export function reportFullyDisplayed(): void { debug.warn('[TimeToDisplay] No active span found to report full display.'); return; } - const spanId = spanToJSON(activeSpan).span_id; - if (spanId && !_imperativeTtfdTimestamps.has(spanId)) { - _imperativeTtfdTimestamps.set(spanId, Date.now() / 1000); + const rootSpan = getRootSpan(activeSpan); + const rootSpanId = spanToJSON(rootSpan).span_id; + if (rootSpanId && !_imperativeTtfdTimestamps.has(rootSpanId)) { + _imperativeTtfdTimestamps.set(rootSpanId, Date.now() / 1000); } } diff --git a/packages/core/test/tracing/timetodisplay.test.tsx b/packages/core/test/tracing/timetodisplay.test.tsx index 04f616ce62..edff0e236e 100644 --- a/packages/core/test/tracing/timetodisplay.test.tsx +++ b/packages/core/test/tracing/timetodisplay.test.tsx @@ -7,6 +7,7 @@ import { getIsolationScope, setCurrentClient, spanToJSON, + startSpan, startSpanManual, } from '@sentry/core'; @@ -1117,6 +1118,41 @@ describe('reportFullyDisplayed', () => { expect(getFullDisplaySpanJSON(client.event!.spans!)).toBeUndefined(); }); + test('works when called inside a nested child span', async () => { + const ttidTimestamp = nowInSeconds(); + + startSpanManual( + { + name: 'Root Manual Span', + startTime: secondAgoTimestampMs(), + }, + (activeSpan: Span | undefined) => { + render(); + + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan).span_id]: ttidTimestamp, + }, + }); + + startSpan({ name: 'child-span' }, () => { + reportFullyDisplayed(); + }); + + activeSpan?.end(); + }, + ); + + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + const ttfdSpan = getFullDisplaySpanJSON(client.event!.spans!); + expect(ttfdSpan).toBeDefined(); + expect(ttfdSpan!.op).toBe('ui.load.full_display'); + expect(ttfdSpan!.status).toBe('ok'); + expectFullDisplayMeasurementOnSpan(client.event!); + }); + test('second call is ignored', async () => { startSpanManual( { From ad541b39e0ec5af2f89aa40824392e0b8fd2efc4 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 8 Jul 2026 13:23:19 +0200 Subject: [PATCH 05/11] fix(tracing): Always pop imperative TTFD timestamp to prevent leak Pop both native and imperative timestamps unconditionally so the imperative entry is cleaned up even when native takes priority. Co-Authored-By: Claude Opus 4.6 --- .../src/js/tracing/integrations/timeToDisplayIntegration.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts index 4bbb7788d4..16444f96a8 100644 --- a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts +++ b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts @@ -225,8 +225,9 @@ async function addTimeToFullDisplay({ transactionStartTimestampSeconds: number; ttidSpan: SpanJSON | undefined; }): Promise { - const ttfdEndTimestampSeconds = - (await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`)) ?? _popImperativeTtfdTimestamp(rootSpanId); + const nativeTtfdTimestamp = await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`); + const imperativeTtfdTimestamp = _popImperativeTtfdTimestamp(rootSpanId); + const ttfdEndTimestampSeconds = nativeTtfdTimestamp ?? imperativeTtfdTimestamp; if (!ttidSpan || !ttfdEndTimestampSeconds) { return undefined; From 2461e4323f60dbc3dd8453e21b1c0c581f30e7ea Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Wed, 8 Jul 2026 13:43:28 +0200 Subject: [PATCH 06/11] fix(tracing): Use timestampInSeconds and cap imperative TTFD map size - Use `timestampInSeconds()` instead of `Date.now() / 1000` for clock consistency with the rest of the tracing module. - Cap `_imperativeTtfdTimestamps` at 50 entries, evicting the oldest when full, to prevent unbounded growth on sampled-out transactions. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/js/tracing/timetodisplay.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index 1dfc46f3d4..112b90afa1 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -12,6 +12,7 @@ import { SPAN_STATUS_OK, spanToJSON, startInactiveSpan, + timestampInSeconds, } from '@sentry/core'; import * as React from 'react'; import { useEffect, useReducer, useRef, useState } from 'react'; @@ -417,10 +418,11 @@ export function updateInitialDisplaySpan( /** * Timestamps stored by the imperative `reportFullyDisplayed()` API. - * Keyed by the active span's span_id at call time. + * Keyed by the root span's span_id at call time. * Consumed by `timeToDisplayIntegration.processEvent`. */ const _imperativeTtfdTimestamps = new Map(); +const MAX_IMPERATIVE_TTFD_ENTRIES = 50; /** * Reports that the screen is fully displayed. @@ -443,7 +445,13 @@ export function reportFullyDisplayed(): void { const rootSpan = getRootSpan(activeSpan); const rootSpanId = spanToJSON(rootSpan).span_id; if (rootSpanId && !_imperativeTtfdTimestamps.has(rootSpanId)) { - _imperativeTtfdTimestamps.set(rootSpanId, Date.now() / 1000); + if (_imperativeTtfdTimestamps.size >= MAX_IMPERATIVE_TTFD_ENTRIES) { + const oldestKey = _imperativeTtfdTimestamps.keys().next().value; + if (oldestKey) { + _imperativeTtfdTimestamps.delete(oldestKey); + } + } + _imperativeTtfdTimestamps.set(rootSpanId, timestampInSeconds()); } } From b6854a233b4bd88e0638370ce7502c0bc2e44f30 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 9 Jul 2026 12:02:07 +0200 Subject: [PATCH 07/11] Update changelog --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ea0280612..6444058d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,17 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Features + +- Add `Sentry.reportFullyDisplayed()` imperative API for signaling Time to Full Display ([#6419](https://github.com/getsentry/sentry-react-native/pull/6419)) + ## 8.18.0 ### Features - Add experimental `extendAppStart`/`finishExtendedAppStart`/`getExtendedAppStartSpan` to extend the standalone app start window and instrument post-init work ([#6392](https://github.com/getsentry/sentry-react-native/pull/6392)) -- Add `Sentry.reportFullyDisplayed()` imperative API for signaling Time to Full Display ([#6419](https://github.com/getsentry/sentry-react-native/pull/6419)) ### Changes From 0d6d0fc69bb251caf1a0f5f1c01bc2d0d99e60d9 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 9 Jul 2026 12:13:37 +0200 Subject: [PATCH 08/11] fix(tracing): Add timestamp guard to TTFD early-return, matching TTID pattern Only return existing ok/unfinished TTFD span when no new timestamp is available, so an imperative timestamp can still update an unfinished span. Co-Authored-By: Claude Opus 4.6 --- .../src/js/tracing/integrations/timeToDisplayIntegration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts index 16444f96a8..a3045e5f1f 100644 --- a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts +++ b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts @@ -237,7 +237,7 @@ async function addTimeToFullDisplay({ let ttfdSpan = event.spans?.find(span => span.op === UI_LOAD_FULL_DISPLAY); - if (ttfdSpan && (ttfdSpan.status === undefined || ttfdSpan.status === 'ok')) { + if (ttfdSpan && (ttfdSpan.status === undefined || ttfdSpan.status === 'ok') && !ttfdEndTimestampSeconds) { debug.log(`[${INTEGRATION_NAME}] Ttfd span already exists and is ok.`, ttfdSpan); return ttfdSpan; } From aecce6e285ec2e5efa1b657cea669deefc1f5af5 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 9 Jul 2026 12:22:12 +0200 Subject: [PATCH 09/11] fix(tracing): Restructure TTFD flow to eliminate dead code in existing-span guard Split the combined `!ttidSpan || !ttfdEndTimestampSeconds` check so the existing ok TTFD span guard runs between them, matching the TTID pattern. Co-Authored-By: Claude Opus 4.6 --- .../src/js/tracing/integrations/timeToDisplayIntegration.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts index a3045e5f1f..33a548185a 100644 --- a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts +++ b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts @@ -229,7 +229,7 @@ async function addTimeToFullDisplay({ const imperativeTtfdTimestamp = _popImperativeTtfdTimestamp(rootSpanId); const ttfdEndTimestampSeconds = nativeTtfdTimestamp ?? imperativeTtfdTimestamp; - if (!ttidSpan || !ttfdEndTimestampSeconds) { + if (!ttidSpan) { return undefined; } @@ -242,6 +242,10 @@ async function addTimeToFullDisplay({ return ttfdSpan; } + if (!ttfdEndTimestampSeconds) { + return undefined; + } + let ttfdAdjustedEndTimestampSeconds = ttfdEndTimestampSeconds; const ttfdIsBeforeTtid = ttidSpan.timestamp && ttfdEndTimestampSeconds < ttidSpan.timestamp; if (ttfdIsBeforeTtid && ttidSpan.timestamp) { From 89786164aaf0ae37da0f281e9b4d65426a5deca2 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 9 Jul 2026 12:57:45 +0200 Subject: [PATCH 10/11] fix(tracing): Update existing TTFD span when both component and imperative API are used Broadens the existing-span update condition to handle spans with undefined or ok status, preventing duplicate ui.load.full_display spans when and reportFullyDisplayed() are both used. Co-Authored-By: Claude Opus 4.6 --- .../src/js/tracing/integrations/timeToDisplayIntegration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts index 33a548185a..f856422f6f 100644 --- a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts +++ b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts @@ -256,7 +256,7 @@ async function addTimeToFullDisplay({ const ttfdStatus = isDeadlineExceeded(durationMs) ? 'deadline_exceeded' : 'ok'; - if (ttfdSpan?.status && ttfdSpan.status !== 'ok') { + if (ttfdSpan) { ttfdSpan.status = ttfdStatus; ttfdSpan.timestamp = ttfdAdjustedEndTimestampSeconds; debug.log(`[${INTEGRATION_NAME}] Updated existing ttfd span.`, ttfdSpan); From 6895568a93b6a1b9896e1e4e4b5ff4e3309dcd91 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 9 Jul 2026 13:11:07 +0200 Subject: [PATCH 11/11] fix(tracing): Fix existing-span update for both TTID and TTFD, add combined-use test Broaden the TTID existing-span update condition to match the TTFD fix, preventing duplicate spans when a component span with ok/undefined status exists alongside a native timestamp. Add test for combined component + reportFullyDisplayed() usage. Co-Authored-By: Claude Opus 4.6 --- .../integrations/timeToDisplayIntegration.ts | 2 +- .../core/test/tracing/timetodisplay.test.tsx | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts index f856422f6f..a5ace98e1d 100644 --- a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts +++ b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts @@ -141,7 +141,7 @@ async function addTimeToInitialDisplay({ const manualDurationMs = (ttidEndTimestampSeconds - transactionStartTimestampSeconds) * 1000; const manualStatus = isDeadlineExceeded(manualDurationMs) ? 'deadline_exceeded' : 'ok'; - if (ttidSpan?.status && ttidSpan.status !== 'ok') { + if (ttidSpan) { ttidSpan.status = manualStatus; ttidSpan.timestamp = ttidEndTimestampSeconds; debug.log(`[${INTEGRATION_NAME}] Updated existing ttid span.`, ttidSpan); diff --git a/packages/core/test/tracing/timetodisplay.test.tsx b/packages/core/test/tracing/timetodisplay.test.tsx index edff0e236e..22d34fa01c 100644 --- a/packages/core/test/tracing/timetodisplay.test.tsx +++ b/packages/core/test/tracing/timetodisplay.test.tsx @@ -1153,6 +1153,46 @@ describe('reportFullyDisplayed', () => { expectFullDisplayMeasurementOnSpan(client.event!); }); + test('does not create duplicate span when component and imperative API are both used', async () => { + const ttidTimestamp = nowInSeconds(); + const ttfdTimestamp = nowInSeconds(); + + startSpanManual( + { + name: 'Root Manual Span', + startTime: secondAgoTimestampMs(), + }, + (activeSpan: Span | undefined) => { + startTimeToInitialDisplaySpan(); + startTimeToFullDisplaySpan(); + + render(); + render(); + + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan).span_id]: ttidTimestamp, + }, + ttfd: { + [spanToJSON(activeSpan).span_id]: ttfdTimestamp, + }, + }); + + reportFullyDisplayed(); + + activeSpan?.end(); + }, + ); + + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + const ttfdSpans = client.event!.spans!.filter((s: SpanJSON) => s.op === 'ui.load.full_display'); + expect(ttfdSpans).toHaveLength(1); + expect(ttfdSpans[0]!.status).toBe('ok'); + expectFullDisplayMeasurementOnSpan(client.event!); + }); + test('second call is ignored', async () => { startSpanManual( {