Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
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';
Expand Down Expand Up @@ -224,7 +225,8 @@
transactionStartTimestampSeconds: number;
ttidSpan: SpanJSON | undefined;
}): Promise<SpanJSON | undefined> {
const ttfdEndTimestampSeconds = await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`);
const ttfdEndTimestampSeconds =
(await NATIVE.popTimeToDisplayFor(`ttfd-${rootSpanId}`)) ?? _popImperativeTtfdTimestamp(rootSpanId);

Check warning on line 229 in packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts

View check run for this annotation

@sentry/warden / warden: code-review

`reportFullyDisplayed()` keys TTFD timestamp by active span ID, but lookup uses root span ID

`reportFullyDisplayed()` stores the TTFD timestamp keyed by `getActiveSpan()`'s `span_id`, but `addTimeToFullDisplay` retrieves it via `_popImperativeTtfdTimestamp(rootSpanId)` where `rootSpanId` is the root transaction span id. When `reportFullyDisplayed()` is called while a nested child span is active (e.g. inside a `Sentry.startSpan(...)` callback or async block), the stored key differs from `rootSpanId`, the lookup returns `undefined`, and the TTFD span is silently never created. Consider deriving the root span via `getRootSpan(activeSpan).spanContext().spanId` (as done in sibling integrations) before storing the timestamp.
Comment thread
antonis marked this conversation as resolved.
Outdated
Comment thread
antonis marked this conversation as resolved.
Outdated

if (!ttidSpan || !ttfdEndTimestampSeconds) {
return undefined;
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/js/tracing/timetodisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,49 @@ export function updateInitialDisplaySpan(
});
}

/**
* 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<string, number>();

/**
* 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 `<TimeToFullDisplay>` 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 spanId = spanToJSON(activeSpan).span_id;
if (spanId && !_imperativeTtfdTimestamps.has(spanId)) {
_imperativeTtfdTimestamps.set(spanId, Date.now() / 1000);
}
}

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) {
Expand Down
156 changes: 156 additions & 0 deletions packages/core/test/tracing/timetodisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
} from '../../src/js/tracing/semanticAttributes';
import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../../src/js/tracing/span';
import {

Check warning on line 33 in packages/core/test/tracing/timetodisplay.test.tsx

View check run for this annotation

@sentry/warden / warden: find-bugs

Duplicate `ui.load.full_display` span when `reportFullyDisplayed()` is called alongside an existing ok TTFD span

In `addTimeToFullDisplay`, if an existing `ui.load.full_display` span with `status === 'ok'` is already present (e.g. from `<TimeToFullDisplay>`) and `reportFullyDisplayed()` also recorded an imperative timestamp, the guard at line 249 (`ttfdSpan.status !== 'ok'`) is `false`, so the function falls through to `createSpanJSON` + `event.spans.push`, emitting a second TTFD span in the same transaction.
Comment thread
antonis marked this conversation as resolved.
_resetImperativeTtfdTimestamps,
reportFullyDisplayed,
startTimeToFullDisplaySpan,
startTimeToInitialDisplaySpan,
TimeToFullDisplay,
Expand Down Expand Up @@ -990,3 +992,157 @@
});
});
});

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

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');
Comment thread
antonis marked this conversation as resolved.
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(<TimeToInitialDisplay record={true} />);

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('second call is ignored', async () => {
startSpanManual(
{
name: 'Root Manual Span',
startTime: secondAgoTimestampMs(),
},
(activeSpan: Span | undefined) => {
render(<TimeToInitialDisplay record={true} />);

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