Skip to content

Commit 26843eb

Browse files
antonisclaude
andauthored
feat(tracing): Add reportFullyDisplayed() static API (#6419)
* 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 `<TimeToFullDisplay>` component. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: Add changelog entry for reportFullyDisplayed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tracing): Route reportFullyDisplayed through the integration 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Update changelog * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <TimeToFullDisplay> and reportFullyDisplayed() are both used. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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 <TimeToFullDisplay> component + reportFullyDisplayed() usage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0b20be6 commit 26843eb

6 files changed

Lines changed: 311 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
77
<!-- prettier-ignore-end -->
88
9+
## Unreleased
10+
11+
### Features
12+
13+
- Add `Sentry.reportFullyDisplayed()` imperative API for signaling Time to Full Display ([#6419](https://github.com/getsentry/sentry-react-native/pull/6419))
14+
915
## 8.18.0
1016

1117
### Features

packages/core/etc/sentry-react-native.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,9 @@ export const reactNavigationIntegration: (input?: Partial<ReactNavigationIntegra
633633
options: ReactNavigationIntegrationOptions;
634634
};
635635

636+
// @public
637+
export function reportFullyDisplayed(): void;
638+
636639
// @public
637640
export function resumeAppHangTracking(): void;
638641

packages/core/src/js/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export {
132132
TimeToFullDisplay,
133133
startTimeToInitialDisplaySpan,
134134
startTimeToFullDisplaySpan,
135+
reportFullyDisplayed,
135136
startIdleNavigationSpan,
136137
startIdleSpan,
137138
getDefaultIdleNavigationSpanOptions,

packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISP
88
import { getReactNavigationIntegration } from '../reactnavigation';
99
import { SEMANTIC_ATTRIBUTE_ROUTE_HAS_BEEN_SEEN } from '../semanticAttributes';
1010
import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../span';
11+
import { _popImperativeTtfdTimestamp } from '../timetodisplay';
1112
import { clearSpan as clearTimeToDisplayCoordinatorSpan } from '../timeToDisplayCoordinator';
1213
import { getTimeToInitialDisplayFallback } from '../timeToDisplayFallback';
1314
import { createSpanJSON } from '../utils';
@@ -140,7 +141,7 @@ async function addTimeToInitialDisplay({
140141
const manualDurationMs = (ttidEndTimestampSeconds - transactionStartTimestampSeconds) * 1000;
141142
const manualStatus = isDeadlineExceeded(manualDurationMs) ? 'deadline_exceeded' : 'ok';
142143

143-
if (ttidSpan?.status && ttidSpan.status !== 'ok') {
144+
if (ttidSpan) {
144145
ttidSpan.status = manualStatus;
145146
ttidSpan.timestamp = ttidEndTimestampSeconds;
146147
debug.log(`[${INTEGRATION_NAME}] Updated existing ttid span.`, ttidSpan);
@@ -224,16 +225,27 @@ async function addTimeToFullDisplay({
224225
transactionStartTimestampSeconds: number;
225226
ttidSpan: SpanJSON | undefined;
226227
}): Promise<SpanJSON | undefined> {
227-
const ttfdEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`);
228+
const nativeTtfdTimestamp = await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`);
229+
const imperativeTtfdTimestamp = _popImperativeTtfdTimestamp(rootSpanId);
230+
const ttfdEndTimestampSeconds = nativeTtfdTimestamp ?? imperativeTtfdTimestamp;
228231

229-
if (!ttidSpan || !ttfdEndTimestampSeconds) {
232+
if (!ttidSpan) {
230233
return undefined;
231234
}
232235

233236
event.spans = event.spans || [];
234237

235238
let ttfdSpan = event.spans?.find(span => span.op === UI_LOAD_FULL_DISPLAY);
236239

240+
if (ttfdSpan && (ttfdSpan.status === undefined || ttfdSpan.status === 'ok') && !ttfdEndTimestampSeconds) {
241+
debug.log(`[${INTEGRATION_NAME}] Ttfd span already exists and is ok.`, ttfdSpan);
242+
return ttfdSpan;
243+
}
244+
245+
if (!ttfdEndTimestampSeconds) {
246+
return undefined;
247+
}
248+
237249
let ttfdAdjustedEndTimestampSeconds = ttfdEndTimestampSeconds;
238250
const ttfdIsBeforeTtid = ttidSpan.timestamp && ttfdEndTimestampSeconds < ttidSpan.timestamp;
239251
if (ttfdIsBeforeTtid && ttidSpan.timestamp) {
@@ -244,7 +256,7 @@ async function addTimeToFullDisplay({
244256

245257
const ttfdStatus = isDeadlineExceeded(durationMs) ? 'deadline_exceeded' : 'ok';
246258

247-
if (ttfdSpan?.status && ttfdSpan.status !== 'ok') {
259+
if (ttfdSpan) {
248260
ttfdSpan.status = ttfdStatus;
249261
ttfdSpan.timestamp = ttfdAdjustedEndTimestampSeconds;
250262
debug.log(`[${INTEGRATION_NAME}] Updated existing ttfd span.`, ttfdSpan);

packages/core/src/js/tracing/timetodisplay.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import {
55
debug,
66
fill,
77
getActiveSpan,
8+
getRootSpan,
89
getSpanDescendants,
910
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
1011
SPAN_STATUS_ERROR,
1112
SPAN_STATUS_OK,
1213
spanToJSON,
1314
startInactiveSpan,
15+
timestampInSeconds,
1416
} from '@sentry/core';
1517
import * as React from 'react';
1618
import { useEffect, useReducer, useRef, useState } from 'react';
@@ -414,6 +416,57 @@ export function updateInitialDisplaySpan(
414416
});
415417
}
416418

419+
/**
420+
* Timestamps stored by the imperative `reportFullyDisplayed()` API.
421+
* Keyed by the root span's span_id at call time.
422+
* Consumed by `timeToDisplayIntegration.processEvent`.
423+
*/
424+
const _imperativeTtfdTimestamps = new Map<string, number>();
425+
const MAX_IMPERATIVE_TTFD_ENTRIES = 50;
426+
427+
/**
428+
* Reports that the screen is fully displayed.
429+
*
430+
* Stores the current timestamp so the Time to Display integration
431+
* can create the TTFD span (`ui.load.full_display`) during event processing.
432+
* If called before TTID completes, the integration adjusts the TTFD
433+
* timestamp to the TTID end (per the cross-SDK spec).
434+
* Subsequent calls for the same span are ignored.
435+
*
436+
* This is the imperative equivalent of the `<TimeToFullDisplay>` component,
437+
* matching the cross-SDK `Sentry.reportFullyDisplayed()` API.
438+
*/
439+
export function reportFullyDisplayed(): void {
440+
const activeSpan = getActiveSpan();
441+
if (!activeSpan) {
442+
debug.warn('[TimeToDisplay] No active span found to report full display.');
443+
return;
444+
}
445+
const rootSpan = getRootSpan(activeSpan);
446+
const rootSpanId = spanToJSON(rootSpan).span_id;
447+
if (rootSpanId && !_imperativeTtfdTimestamps.has(rootSpanId)) {
448+
if (_imperativeTtfdTimestamps.size >= MAX_IMPERATIVE_TTFD_ENTRIES) {
449+
const oldestKey = _imperativeTtfdTimestamps.keys().next().value;
450+
if (oldestKey) {
451+
_imperativeTtfdTimestamps.delete(oldestKey);
452+
}
453+
}
454+
_imperativeTtfdTimestamps.set(rootSpanId, timestampInSeconds());
455+
}
456+
}
457+
458+
export function _popImperativeTtfdTimestamp(spanId: string): number | undefined {
459+
const ts = _imperativeTtfdTimestamps.get(spanId);
460+
if (ts !== undefined) {
461+
_imperativeTtfdTimestamps.delete(spanId);
462+
}
463+
return ts;
464+
}
465+
466+
export function _resetImperativeTtfdTimestamps(): void {
467+
_imperativeTtfdTimestamps.clear();
468+
}
469+
417470
function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDisplaySpan?: Span): void {
418471
const activeSpan = getActiveSpan();
419472
if (!activeSpan) {

0 commit comments

Comments
 (0)