Skip to content

Commit 9adc127

Browse files
antonisclaude
andcommitted
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>
1 parent 4acd4e9 commit 9adc127

4 files changed

Lines changed: 194 additions & 9 deletions

File tree

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/timetodisplay.tsx

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@ const FRAME_DATA_CLEANUP_TIMEOUT_MS = 60_000;
4141
export const manualInitialDisplaySpans = new WeakMap<Span, true>();
4242

4343
/**
44-
* Flag full display called before initial display for an active span.
44+
* Stores deferred full display state when reportFullyDisplayed / updateFullDisplaySpan
45+
* is called before TTID completes. Preserves the isAutoInstrumented flag so the
46+
* correct span origin is applied when the deferred call fires.
4547
*/
46-
const fullDisplayBeforeInitialDisplay = new WeakMap<Span, true>();
48+
const fullDisplayBeforeInitialDisplay = new WeakMap<Span, { isAutoInstrumented: boolean }>();
4749

4850
interface FrameDataForSpan {
4951
startFrames: NativeFramesResponse | null;
@@ -391,10 +393,11 @@ export function updateInitialDisplaySpan(
391393
span.setStatus({ code: SPAN_STATUS_OK });
392394
debug.log(`[TimeToDisplay] ${spanToJSON(span).description} span updated with end timestamp and frame data.`);
393395

394-
if (fullDisplayBeforeInitialDisplay.has(activeSpan)) {
396+
const deferred = fullDisplayBeforeInitialDisplay.get(activeSpan);
397+
if (deferred) {
395398
fullDisplayBeforeInitialDisplay.delete(activeSpan);
396399
debug.log(`[TimeToDisplay] Updating full display with initial display (${span.spanContext().spanId}) end.`);
397-
updateFullDisplaySpan(frameTimestampSeconds, span);
400+
updateFullDisplaySpan(frameTimestampSeconds, span, deferred.isAutoInstrumented);
398401
}
399402

400403
setSpanDurationAsMeasurementOnSpan('time_to_initial_display', span, activeSpan);
@@ -404,17 +407,36 @@ export function updateInitialDisplaySpan(
404407
span.end(frameTimestampSeconds);
405408
span.setStatus({ code: SPAN_STATUS_OK });
406409

407-
if (fullDisplayBeforeInitialDisplay.has(activeSpan)) {
410+
const deferred = fullDisplayBeforeInitialDisplay.get(activeSpan);
411+
if (deferred) {
408412
fullDisplayBeforeInitialDisplay.delete(activeSpan);
409413
debug.log(`[TimeToDisplay] Updating full display with initial display (${span.spanContext().spanId}) end.`);
410-
updateFullDisplaySpan(frameTimestampSeconds, span);
414+
updateFullDisplaySpan(frameTimestampSeconds, span, deferred.isAutoInstrumented);
411415
}
412416

413417
setSpanDurationAsMeasurementOnSpan('time_to_initial_display', span, activeSpan);
414418
});
415419
}
416420

417-
function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDisplaySpan?: Span): void {
421+
/**
422+
* Reports that the screen is fully displayed.
423+
*
424+
* Ends the TTFD span (`ui.load.full_display`) with the current timestamp.
425+
* If called before TTID completes, the TTFD span is deferred until TTID ends.
426+
* Subsequent calls are ignored once the span has ended.
427+
*
428+
* This is the imperative equivalent of the `<TimeToFullDisplay>` component,
429+
* matching the cross-SDK `Sentry.reportFullyDisplayed()` API.
430+
*/
431+
export function reportFullyDisplayed(): void {
432+
updateFullDisplaySpan(Date.now() / 1000, undefined, false);
433+
}
434+
435+
function updateFullDisplaySpan(
436+
frameTimestampSeconds: number,
437+
passedInitialDisplaySpan?: Span,
438+
isAutoInstrumented: boolean = true,
439+
): void {
418440
const activeSpan = getActiveSpan();
419441
if (!activeSpan) {
420442
debug.warn('[TimeToDisplay] No active span found to update ui.load.full_display in.');
@@ -426,15 +448,15 @@ function updateFullDisplaySpan(frameTimestampSeconds: number, passedInitialDispl
426448
getSpanDescendants(activeSpan).find(span => spanToJSON(span).op === 'ui.load.initial_display');
427449
const initialDisplayEndTimestamp = existingInitialDisplaySpan && spanToJSON(existingInitialDisplaySpan).timestamp;
428450
if (!initialDisplayEndTimestamp) {
429-
fullDisplayBeforeInitialDisplay.set(activeSpan, true);
451+
fullDisplayBeforeInitialDisplay.set(activeSpan, { isAutoInstrumented });
430452
debug.warn(
431453
`[TimeToDisplay] Full display called before initial display for active span (${activeSpan.spanContext().spanId}).`,
432454
);
433455
return;
434456
}
435457

436458
const span = startTimeToFullDisplaySpan({
437-
isAutoInstrumented: true,
459+
isAutoInstrumented,
438460
});
439461
if (!span) {
440462
debug.warn('[TimeToDisplay] No TimeToFullDisplay span found or created, possibly performance is disabled.');

packages/core/test/tracing/timetodisplay.test.tsx

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
} from '../../src/js/tracing/semanticAttributes';
3232
import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span';
3333
import {
34+
reportFullyDisplayed,
3435
startTimeToFullDisplaySpan,
3536
startTimeToInitialDisplaySpan,
3637
TimeToFullDisplay,
@@ -990,3 +991,161 @@ describe('Frame Data', () => {
990991
});
991992
});
992993
});
994+
995+
describe('reportFullyDisplayed', () => {
996+
let client: TestClient;
997+
998+
beforeEach(() => {
999+
clearMockedOnDrawReportedProps();
1000+
_resetTimeToDisplayCoordinator();
1001+
getCurrentScope().clear();
1002+
getIsolationScope().clear();
1003+
getGlobalScope().clear();
1004+
1005+
const options = getDefaultTestClientOptions({
1006+
tracesSampleRate: 1.0,
1007+
});
1008+
client = new TestClient({
1009+
...options,
1010+
integrations: [...options.integrations, timeToDisplayIntegration()],
1011+
});
1012+
setCurrentClient(client);
1013+
client.init();
1014+
});
1015+
1016+
afterEach(() => {
1017+
jest.clearAllMocks();
1018+
mockWrapper.NATIVE.enableNative = true;
1019+
});
1020+
1021+
test('creates full display span with manual origin and measurement', async () => {
1022+
await startSpanManual(
1023+
{
1024+
name: 'Root Manual Span',
1025+
startTime: secondAgoTimestampMs(),
1026+
},
1027+
async (activeSpan: Span | undefined) => {
1028+
const ttidSpan = startTimeToInitialDisplaySpan();
1029+
render(<TimeToInitialDisplay record={true} />);
1030+
1031+
await Promise.resolve();
1032+
await Promise.resolve();
1033+
await Promise.resolve();
1034+
1035+
updateInitialDisplaySpan(nowInSeconds(), { activeSpan, span: ttidSpan });
1036+
1037+
for (let i = 0; i < 15; i++) {
1038+
await Promise.resolve();
1039+
}
1040+
1041+
reportFullyDisplayed();
1042+
1043+
for (let i = 0; i < 15; i++) {
1044+
await Promise.resolve();
1045+
}
1046+
1047+
activeSpan?.end();
1048+
},
1049+
);
1050+
1051+
await jest.runOnlyPendingTimersAsync();
1052+
await client.flush();
1053+
1054+
const ttfdSpan = getFullDisplaySpanJSON(client.event!.spans!);
1055+
expect(ttfdSpan).toBeDefined();
1056+
expect(ttfdSpan!.op).toBe('ui.load.full_display');
1057+
expect(ttfdSpan!.status).toBe('ok');
1058+
expect(ttfdSpan!.timestamp).toBeDefined();
1059+
expect(ttfdSpan!.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe(SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY);
1060+
expectFullDisplayMeasurementOnSpan(client.event!);
1061+
});
1062+
1063+
test('defers full display when called before initial display with correct origin', async () => {
1064+
await startSpanManual(
1065+
{
1066+
name: 'Root Manual Span',
1067+
startTime: secondAgoTimestampMs(),
1068+
},
1069+
async (activeSpan: Span | undefined) => {
1070+
const ttidSpan = startTimeToInitialDisplaySpan();
1071+
render(<TimeToInitialDisplay record={true} />);
1072+
1073+
await Promise.resolve();
1074+
await Promise.resolve();
1075+
await Promise.resolve();
1076+
1077+
reportFullyDisplayed();
1078+
1079+
updateInitialDisplaySpan(nowInSeconds(), { activeSpan, span: ttidSpan });
1080+
1081+
for (let i = 0; i < 15; i++) {
1082+
await Promise.resolve();
1083+
}
1084+
1085+
activeSpan?.end();
1086+
},
1087+
);
1088+
1089+
await jest.runOnlyPendingTimersAsync();
1090+
await client.flush();
1091+
1092+
const ttfdSpan = getFullDisplaySpanJSON(client.event!.spans!);
1093+
expect(ttfdSpan).toBeDefined();
1094+
expect(ttfdSpan!.op).toBe('ui.load.full_display');
1095+
expect(ttfdSpan!.status).toBe('ok');
1096+
expect(ttfdSpan!.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe(SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY);
1097+
1098+
const ttidSpanJSON = getInitialDisplaySpanJSON(client.event!.spans!);
1099+
expect(ttfdSpan!.timestamp).toEqual(ttidSpanJSON!.timestamp);
1100+
1101+
expectFullDisplayMeasurementOnSpan(client.event!);
1102+
});
1103+
1104+
test('does nothing without an active span', () => {
1105+
expect(() => reportFullyDisplayed()).not.toThrow();
1106+
expect(debug.warn).toHaveBeenCalledWith(expect.stringContaining('No active span found'));
1107+
});
1108+
1109+
test('second call is ignored after span already ended', async () => {
1110+
await startSpanManual(
1111+
{
1112+
name: 'Root Manual Span',
1113+
startTime: secondAgoTimestampMs(),
1114+
},
1115+
async (activeSpan: Span | undefined) => {
1116+
const ttidSpan = startTimeToInitialDisplaySpan();
1117+
render(<TimeToInitialDisplay record={true} />);
1118+
1119+
await Promise.resolve();
1120+
await Promise.resolve();
1121+
await Promise.resolve();
1122+
1123+
updateInitialDisplaySpan(nowInSeconds(), { activeSpan, span: ttidSpan });
1124+
1125+
for (let i = 0; i < 15; i++) {
1126+
await Promise.resolve();
1127+
}
1128+
1129+
reportFullyDisplayed();
1130+
1131+
for (let i = 0; i < 15; i++) {
1132+
await Promise.resolve();
1133+
}
1134+
1135+
reportFullyDisplayed();
1136+
1137+
for (let i = 0; i < 15; i++) {
1138+
await Promise.resolve();
1139+
}
1140+
1141+
activeSpan?.end();
1142+
},
1143+
);
1144+
1145+
await jest.runOnlyPendingTimersAsync();
1146+
await client.flush();
1147+
1148+
const ttfdSpans = client.event!.spans!.filter((s: SpanJSON) => s.op === 'ui.load.full_display');
1149+
expect(ttfdSpans).toHaveLength(1);
1150+
});
1151+
});

0 commit comments

Comments
 (0)