getsentry / getsentry/sentry-dotnet
Implement Time to Full Display on Android
- 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 Android
TTFD uses the same native draw-detection mechanism as TTID: `FirstDrawDoneListener` from `sentry-android-core`. The difference is purely in *which* timestamp is treated as the completion point — for TTID it is automatic (first frame of the screen), for TTFD it is the frame that renders after `reportFullyDisplayed()` is called (or the component prop is toggled).
### Component Path (``)
A `RNSentryOnDrawReporter` view is embedded in the screen's component tree with `fullDisplay={true}` and `parentSpanId`. When rendered:
1. Calls `FirstDrawDoneListener.registerForNextDraw(activity, callback, buildInfo)` from the Sentry Android SDK
2. `FirstDrawDoneListener` is an `OnDrawListener` on the Activity's `ViewTreeObserver` — fires once on the next draw pass
3. Records `SentryAndroidDateProvider.now().nanoTimestamp() / 1e9` and stores it in a bounded LRU map (`RNSentryTimeToDisplay`) under `"ttfd-{parentSpanId}"`
### 30-Second Timeout
The JS integration starts a `setTimeout` of 30 000 ms when the TTFD span is created. If `reportFullyDisplayed()` has not been called by then:
* The span status is set to `deadline_exceeded`
* The span 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
Two enforcement points ensure TTFD never ends before TTID:
1. If the native draw callback fires before TTID has a timestamp, a `fullDisplayBeforeInitialDisplay` flag is set and the TTFD end is deferred 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 from that adjusted start — identical to the TTID alignment described in `ttid-android.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 draw 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 Android API: `FirstDrawDoneListener`
The same `FirstDrawDoneListener` from `sentry-android-core` used for TTID. Since the Android SDK is already embedded in our project, this may be directly callable from the managed .NET layer — worth verifying before implementing an independent `OnDrawListener`.
---
## Implications for .NET/MAUI (Android)
* The native draw mechanism is the same as TTID — `FirstDrawDoneListener` / `ViewTreeObserver.OnDrawListener`. No additional Android API is needed beyond what TTID requires
* The main difference from TTID is that the draw listener is registered *in response to a user action* (calling `reportFullyDisplayed()`), not automatically on screen creation
* 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
* Frame data (`frames.total`, `frames.slow`, `frames.frozen`) should be attached to the TTFD span as span attributes, as with TTID
Contributor guide
Assessment
This issue has not been assessed yet.