getsentry / getsentry/sentry-dotnet
Implement Time to Full Display on iOS
- 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
Assessment
This issue has not been assessed yet.