getsentry / getsentry/sentry-dotnet

Implement Time to Full Display on iOS

Open
#5,104 1 comment 0 reactions 0 assignees View on GitHub
.NET Feature Spans
Dominant language
C#
Stars
770
Forks
248
Avg merge
3d 4h
Merged PRs (30d)
49

Description

# Desrciption

Part of

* [NET-185](https://linear.app/getsentry/issue/NET-185/mobile-vitals)

## What Is TTFD?

TTFD measures the time from when a screen begins loading until it is **fully loaded with all its data**. Unlike TTID (which fires automatically at first frame), TTFD is finished **manually** by the developer calling `Sentry.reportFullyDisplayed()` once the screen has all its content.

* **Span op**: `ui.load.full_display`
* **Measurement key**: `time_to_full_display` (milliseconds)
* The span is a child of the `ui.load` transaction (does not count against quota)
* **Opt-in only** — must be enabled via `enableTimeToFullDisplayTracing` (disabled by default)
* Status must be `ok` for the measurement to be recorded; deadline-exceeded spans get the TTID measurement value instead
* Requires TTID to exist — if no TTID span is found, TTFD is not created at all

### References

* [TTFD spec]()
* [Mobile Vitals product docs]()
* [sentry-dotnet tracking issue]()

---

## Span Lifecycle and Status Rules

| Scenario | End timestamp | Status |
| -- | -- | -- |
| `reportFullyDisplayed()` called after TTID finishes | Timestamp of the API call | `ok` |
| `reportFullyDisplayed()` called before TTID finishes | Clamped forward to TTID end timestamp | `ok` |
| 30-second timeout fires | TTID end timestamp | `deadline_exceeded` |
| New screen navigation begins before TTFD finishes | TTID end timestamp | `deadline_exceeded` |
| `enableTimeToFullDisplayTracing` disabled | Span never created; API calls ignored | — |
| No active screen-load transaction | API call ignored | — |

When the deadline is exceeded, `time_to_full_display` is set equal to `time_to_initial_display` — not to 30 seconds.

---

## How React Native Implements TTFD on iOS

The iOS implementation mirrors Android in structure but uses the Cocoa SDK's `SentryFramesTracker` (via `CADisplayLink`) rather than `ViewTreeObserver`. The same two-path architecture applies.

### Component Path (``)

A `RNSentryOnDrawReporterView` (subclass of `UIView`) is embedded in the screen's component tree with `fullDisplay={true}` and `parentSpanId`. When rendered:

1. A `RNSentryFramesTrackerListener` is attached to the Cocoa SDK's `SentryFramesTracker`
2. `SentryFramesTracker` is driven by a `CADisplayLink` internally. On the **next frame callback** it calls `framesTrackerHasNewFrame:(NSDate *)newFrameDate`
3. The listener removes itself immediately (one-shot), then stores `[newFrameDate timeIntervalSince1970]` in a bounded LRU map (`RNSentryTimeToDisplay`) under `"ttfd-{parentSpanId}"`

### 30-Second Timeout

Identical to Android — a `setTimeout` of 30 000 ms is set in JS when the span is created. On expiry:

* Span status is set to `deadline_exceeded`
* End timestamp is clamped to the TTID span's end timestamp
* `time_to_full_display` measurement is set equal to `time_to_initial_display`

Calling `reportFullyDisplayed()` manually cancels the timer.

### TTFD vs TTID Ordering Constraint

Same two enforcement points as Android:

1. If the frame callback fires before TTID has a timestamp, a `fullDisplayBeforeInitialDisplay` flag defers the TTFD end until TTID finishes
2. In post-processing (`processEvent`): if `ttfdEnd < ttidEnd`, the TTFD end is clamped to `ttidEnd`

### App Start Timestamp Alignment

On the first screen (app startup), both TTID and TTFD `start_timestamp` are backdated to the native app start timestamp, and measurements are recomputed — identical to the alignment described in `ttid-ios.md`.

### JS-Side Span Construction

Assembled in `processEvent` after the transaction flushes (not as a live span):

1. `NATIVE.popTimeToDisplayFor("ttfd-{rootSpanId}")` retrieves the stored frame timestamp
2. End timestamp is clamped to TTID end if needed, then checked against 30 000 ms for deadline
3. A `SpanJSON` is constructed with `op: "ui.load.full_display"`, `origin: "manual.ui.time_to_display"`
4. `time_to_full_display` measurement set from duration if `ok`, or copied from `time_to_initial_display` if `deadline_exceeded`

---

## Key iOS API: `SentryFramesTracker` / `CADisplayLink`

The same `SentryFramesTracker` used for TTID and slow/frozen frames. TTFD uses it in exactly the same one-shot listener pattern — the only difference is what the timestamp represents (a frame rendered after the user signals completion, rather than the first frame of the screen).

`SentryFramesTracker` must be running (`enableAutoPerformanceTracing` set on the Cocoa SDK) for this to work. If it is not running, the draw callback never fires and TTFD falls back to the 30-second deadline path.

---

## Quirks and Limitations

* Non-iOS targets (`TARGET_OS_IOS` guard) — the `CADisplayLink` fallback returns empty on macOS / Catalyst; the `SentryFramesTracker` path has the same platform restriction
* Both iOS and Android cap the timestamp store at 50 concurrent entries (LRU eviction) to prevent memory leaks
* Frame data (`frames.total`, `frames.slow`, `frames.frozen`) is attached to the TTFD span as span attributes using the same `spanFrameDataMap` as TTID, with a 60-second cleanup for spans that never end

---

## Implications for .NET/MAUI (iOS)

* The native draw mechanism is the same as TTID — `SentryFramesTracker` / `CADisplayLink` via `PrivateSentrySDKOnly`. No additional Cocoa binding work beyond what TTID requires
* The main difference from TTID is that the frame listener is registered *in response to a user action* (calling `ReportFullyDisplayed()`), not automatically on screen appearance
* The .NET public API should be `SentrySDK.ReportFullyDisplayed()` (or similar), gated behind `EnableTimeToFullDisplayTracing` in `SentryOptions`
* The 30-second deadline and TTFD-cannot-precede-TTID constraint both need to be implemented in managed code
* `enableAutoPerformanceTracing` must be set on the Cocoa SDK options for `SentryFramesTracker` to be running
* Frame data attributes should be attached to the TTFD span, matching the TTID behaviour

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.