getsentry / getsentry/sentry-dotnet

Implement Time to Full Display on Android

Open
#5,103 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 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

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.