diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f710fc4eb..6444058d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ > 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 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 { - const ttfdEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`); + const nativeTtfdTimestamp = await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`); + const imperativeTtfdTimestamp = _popImperativeTtfdTimestamp(rootSpanId); + const ttfdEndTimestampSeconds = nativeTtfdTimestamp ?? imperativeTtfdTimestamp; - if (!ttidSpan || !ttfdEndTimestampSeconds) { + if (!ttidSpan) { return undefined; } @@ -234,6 +237,15 @@ async function addTimeToFullDisplay({ let ttfdSpan = event.spans?.find(span => span.op === UI_LOAD_FULL_DISPLAY); + if (ttfdSpan && (ttfdSpan.status === undefined || ttfdSpan.status === 'ok') && !ttfdEndTimestampSeconds) { + debug.log(`[${INTEGRATION_NAME}] Ttfd span already exists and is ok.`, ttfdSpan); + return ttfdSpan; + } + + if (!ttfdEndTimestampSeconds) { + return undefined; + } + let ttfdAdjustedEndTimestampSeconds = ttfdEndTimestampSeconds; const ttfdIsBeforeTtid = ttidSpan.timestamp && ttfdEndTimestampSeconds < ttidSpan.timestamp; if (ttfdIsBeforeTtid && ttidSpan.timestamp) { @@ -244,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); diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index 09aeecac57..112b90afa1 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -5,12 +5,14 @@ import { debug, fill, getActiveSpan, + getRootSpan, getSpanDescendants, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, SPAN_STATUS_OK, spanToJSON, startInactiveSpan, + timestampInSeconds, } from '@sentry/core'; import * as React from 'react'; import { useEffect, useReducer, useRef, useState } from 'react'; @@ -414,6 +416,57 @@ export function updateInitialDisplaySpan( }); } +/** + * Timestamps stored by the imperative `reportFullyDisplayed()` API. + * 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. + * + * 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 { + const activeSpan = getActiveSpan(); + if (!activeSpan) { + debug.warn('[TimeToDisplay] No active span found to report full display.'); + return; + } + const rootSpan = getRootSpan(activeSpan); + const rootSpanId = spanToJSON(rootSpan).span_id; + if (rootSpanId && !_imperativeTtfdTimestamps.has(rootSpanId)) { + if (_imperativeTtfdTimestamps.size >= MAX_IMPERATIVE_TTFD_ENTRIES) { + const oldestKey = _imperativeTtfdTimestamps.keys().next().value; + if (oldestKey) { + _imperativeTtfdTimestamps.delete(oldestKey); + } + } + _imperativeTtfdTimestamps.set(rootSpanId, timestampInSeconds()); + } +} + +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) { diff --git a/packages/core/test/tracing/timetodisplay.test.tsx b/packages/core/test/tracing/timetodisplay.test.tsx index 991aa9043e..22d34fa01c 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'; @@ -31,6 +32,8 @@ import { } from '../../src/js/tracing/semanticAttributes'; import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span'; import { + _resetImperativeTtfdTimestamps, + reportFullyDisplayed, startTimeToFullDisplaySpan, startTimeToInitialDisplaySpan, TimeToFullDisplay, @@ -990,3 +993,232 @@ describe('Frame Data', () => { }); }); }); + +describe('reportFullyDisplayed', () => { + let client: TestClient; + + beforeEach(() => { + clearMockedOnDrawReportedProps(); + _resetTimeToDisplayCoordinator(); + _resetImperativeTtfdTimestamps(); + 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 measurement via integration', async () => { + const ttidTimestamp = nowInSeconds(); + + startSpanManual( + { + name: 'Root Manual Span', + startTime: secondAgoTimestampMs(), + }, + (activeSpan: Span | undefined) => { + render(); + + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan).span_id]: ttidTimestamp, + }, + }); + + reportFullyDisplayed(); + + activeSpan?.end(); + }, + ); + + 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(); + expectFullDisplayMeasurementOnSpan(client.event!); + }); + + test('adjusts ttfd to ttid end when reported before ttid', async () => { + const ttidTimestamp = secondInFutureTimestampMs() / 1000; + + startSpanManual( + { + name: 'Root Manual Span', + startTime: secondAgoTimestampMs(), + }, + (activeSpan: Span | undefined) => { + render(); + + reportFullyDisplayed(); + + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan).span_id]: ttidTimestamp, + }, + }); + + activeSpan?.end(); + }, + ); + + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + const ttfdSpan = getFullDisplaySpanJSON(client.event!.spans!); + expect(ttfdSpan).toBeDefined(); + expect(ttfdSpan!.status).toBe('ok'); + + 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('does not create ttfd span without ttid', async () => { + startSpanManual( + { + name: 'Root Manual Span', + startTime: secondAgoTimestampMs(), + }, + (activeSpan: Span | undefined) => { + reportFullyDisplayed(); + activeSpan?.end(); + }, + ); + + await jest.runOnlyPendingTimersAsync(); + await client.flush(); + + expectNoFullDisplayMeasurementOnSpan(client.event!); + 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('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( + { + name: 'Root Manual Span', + startTime: secondAgoTimestampMs(), + }, + (activeSpan: Span | undefined) => { + render(); + + mockRecordedTimeToDisplay({ + ttid: { + [spanToJSON(activeSpan).span_id]: nowInSeconds(), + }, + }); + + reportFullyDisplayed(); + 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); + }); +});