Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
> make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first.
<!-- prettier-ignore-end -->
## 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
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 { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISP
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 @@ -140,7 +141,7 @@ async function addTimeToInitialDisplay({
const manualDurationMs = (ttidEndTimestampSeconds - transactionStartTimestampSeconds) * 1000;
const manualStatus = isDeadlineExceeded(manualDurationMs) ? 'deadline_exceeded' : 'ok';

if (ttidSpan?.status && ttidSpan.status !== 'ok') {
if (ttidSpan) {
ttidSpan.status = manualStatus;
ttidSpan.timestamp = ttidEndTimestampSeconds;
debug.log(`[${INTEGRATION_NAME}] Updated existing ttid span.`, ttidSpan);
Expand Down Expand Up @@ -224,16 +225,27 @@ async function addTimeToFullDisplay({
transactionStartTimestampSeconds: number;
ttidSpan: SpanJSON | undefined;
}): Promise<SpanJSON | undefined> {
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;
}

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

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;
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
antonis marked this conversation as resolved.
Comment thread
antonis marked this conversation as resolved.

if (!ttfdEndTimestampSeconds) {
return undefined;
}

let ttfdAdjustedEndTimestampSeconds = ttfdEndTimestampSeconds;
const ttfdIsBeforeTtid = ttidSpan.timestamp && ttfdEndTimestampSeconds < ttidSpan.timestamp;
if (ttfdIsBeforeTtid && ttidSpan.timestamp) {
Comment thread
sentry[bot] marked this conversation as resolved.
Expand All @@ -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);
Expand Down
53 changes: 53 additions & 0 deletions packages/core/src/js/tracing/timetodisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, number>();
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 `<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 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) {
Expand Down
Loading
Loading