Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,9 @@ export const reactNavigationIntegration: (input?: Partial<ReactNavigationIntegra
options: ReactNavigationIntegrationOptions;
};

// @public
export function reportFullyDisplayed(): void;

// @public
export function resumeAppHangTracking(): void;

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export {
TimeToFullDisplay,
startTimeToInitialDisplaySpan,
startTimeToFullDisplaySpan,
reportFullyDisplayed,
startIdleNavigationSpan,
startIdleSpan,
getDefaultIdleNavigationSpanOptions,
Expand Down
40 changes: 31 additions & 9 deletions packages/core/src/js/tracing/timetodisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ const FRAME_DATA_CLEANUP_TIMEOUT_MS = 60_000;
export const manualInitialDisplaySpans = new WeakMap<Span, true>();

/**
* 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<Span, true>();
const fullDisplayBeforeInitialDisplay = new WeakMap<Span, { isAutoInstrumented: boolean }>();

interface FrameDataForSpan {
startFrames: NativeFramesResponse | null;
Expand Down Expand Up @@ -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);
Expand All @@ -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 `<TimeToFullDisplay>` 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.');
Expand All @@ -426,15 +448,15 @@ 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 });
Comment thread
antonis marked this conversation as resolved.
Outdated
debug.warn(
`[TimeToDisplay] Full display called before initial display for active span (${activeSpan.spanContext().spanId}).`,
);
return;
}

const span = startTimeToFullDisplaySpan({
isAutoInstrumented: true,
isAutoInstrumented,
});
if (!span) {
debug.warn('[TimeToDisplay] No TimeToFullDisplay span found or created, possibly performance is disabled.');
Expand Down
159 changes: 159 additions & 0 deletions packages/core/test/tracing/timetodisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
} from '../../src/js/tracing/semanticAttributes';
import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span';
import {
Comment thread
antonis marked this conversation as resolved.
reportFullyDisplayed,
startTimeToFullDisplaySpan,
startTimeToInitialDisplaySpan,
TimeToFullDisplay,
Expand Down Expand Up @@ -990,3 +991,161 @@
});
});
});

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(<TimeToInitialDisplay record={true} />);

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');

Check failure on line 1057 in packages/core/test/tracing/timetodisplay.test.tsx

View check run for this annotation

@sentry/warden / warden: find-bugs

TTFD deadline timer set to 0ms because `updateFullDisplaySpan` calls `startTimeToFullDisplaySpan` without `timeoutMs`

`updateFullDisplaySpan` calls `startTimeToFullDisplaySpan({ isAutoInstrumented })` (timetodisplay.tsx:458), passing a defined `options` object that omits `timeoutMs`. Because the function's default parameter `{ timeoutMs: 30_000 }` only applies when the entire `options` argument is `undefined`, `options.timeoutMs` is `undefined` on this path. The deadline is created via `setTimeout(fn, options.timeoutMs)` (line 338) with no fallback, so `setTimeout(fn, undefined)` normalises to a 0ms timer. When native frame capture is enabled, `captureEndFramesAndAttachToSpan` awaits a native bridge call that resolves on a later macrotask, so the 0ms deadline callback fires first, marks the TTFD span `deadline_exceeded`, and ends it with the TTID end timestamp instead of the real full-display timestamp. This affects both the imperative `reportFullyDisplayed()` path and the `<TimeToFullDisplay>` component path, since both route through `updateFullDisplaySpan`.
Comment thread
antonis marked this conversation as resolved.
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(<TimeToInitialDisplay record={true} />);

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(<TimeToInitialDisplay record={true} />);

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);
});
});
Loading