getsentry / getsentry/sentry-dotnet

Implement Time to Initial Display on Android

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

See also:
- https://github.com/getsentry/sentry-dotnet/issues/3965

## What Is TTID?

TTID measures the time from when a new screen begins loading until the screen draws its **first frame**. It gives a more accurate picture of screen creation time than raw transaction timing.

* **Span op**: `ui.load.initial_display`
* **Measurement key**: `time_to_initial_display` (milliseconds)
* The span is a child of the `ui.load` transaction (not a separate transaction, so it doesn't count against quota)
* Status must be `ok` for the measurement to be recorded — if the span finishes with any other status the measurement is omitted
* The span **starts** when the screen begins loading. During app startup this is adjusted back to the native app start timestamp (same alignment as App Starts)
* The span **ends** automatically when the first frame is drawn — no manual intervention needed

### References

* Initial PR from Allan Ritchie (old - needs review)
* #4088
* [TTID spec]()
* [Mobile Vitals product docs]()
* [sentry-dotnet tracking issue]()

---

## How React Native Implements TTID on Android

RN has two parallel native paths for detecting first draw, plus a JS fallback. All paths ultimately store a timestamp in a shared static map (`RNSentryTimeToDisplay`) keyed by span ID. The JS layer reads this map after the transaction ends and constructs the TTID span.

### Path A — View-based (`RNSentryOnDrawReporter`)

A special view component (``) is embedded in the screen's component tree. When rendered with `initialDisplay=true`:

1. Calls `FirstDrawDoneListener.registerForNextDraw(activity, callback, buildInfo)` from the Sentry Android SDK
2. `FirstDrawDoneListener` is an `OnDrawListener` on the Activity's `ViewTreeObserver`
3. On the **next draw pass** it captures `SentryAndroidDateProvider.now().nanoTimestamp() / 1e9`
4. Stores the timestamp in the map under `"ttid-{parentSpanId}"`

### Path B — Fragment lifecycle (`RNSentryReactFragmentLifecycleTracer`)

Activated when `enableTimeToInitialDisplay=true` in the React Navigation integration. Requires `react-native-screens` (specifically `ScreenStackFragment`):

1. JS calls `NATIVE.setActiveSpanId(spanId)` to register the current span on the native side
2. A `FragmentLifecycleCallbacks` listener watches for `ScreenStackFragment` view creation
3. On `ScreenAppearEvent` (screen visible), calls `FirstDrawDoneListener.registerForNextDraw(...)`
4. Stores timestamp under `"ttid-navigation-{activeSpanId}"`

### Fallback — JS Choreographer bridge (`getNewScreenTimeToDisplay`)

If neither native path produces a timestamp in time, the JS side calls `NATIVE.getNewScreenTimeToDisplay()` which:

1. Posts to the main `Looper`
2. Uses `Choreographer.getInstance().postFrameCallback(...)` to capture the timestamp of the next rendered frame in nanoseconds
3. Resolves the promise with `nanoTimestamp / 1e9` (seconds)

### JS-Side Span Construction

After the transaction ends, in `processEvent`:

1. Calls `NATIVE.popTimeToDisplayFor("ttid-{rootSpanId}")` (manual) or `"ttid-navigation-{rootSpanId}"` (automatic navigation path), with the Choreographer fallback as last resort
2. Constructs a `SpanJSON` with `op: "ui.load.initial_display"`, `start_timestamp: transactionStartTimestamp`, `timestamp: ttidTimestamp`
3. Sets `time_to_initial_display` measurement as `(timestamp - start_timestamp) * 1000` ms

### App Start Timestamp Alignment

When processing an app-start transaction, both the transaction's `start_timestamp` and the TTID span's `start_timestamp` are overwritten with the native app start timestamp. The measurement is recalculated after this adjustment.

---

## Key Android API: `FirstDrawDoneListener`

This class from the Sentry Android SDK is the core mechanism on both native paths. It wraps `ViewTreeObserver.addOnDrawListener` and fires exactly once on the next draw pass.

Whether `.NET/MAUI` can use this directly (since the Android SDK is already embedded) is worth investigating — it may avoid needing to implement our own `OnDrawListener`.

---

## Quirks and Limitations

* The two native paths (view-based and fragment-based) can run in parallel. The map stores results by span ID so they don't collide
* `activeSpanId` is a single global on the native side — if navigation happens faster than the frame callback, a span ID mismatch is possible (the keyed map mitigates this)
* The fragment lifecycle path silently skips if `react-native-screens` is absent or the screen is not a `ScreenStackFragment`
* Back navigation transactions explicitly do not receive TTID spans/measurements by default
* A 30-second timeout in `timeToDisplayIntegration.ts` bounds the window for receiving a native timestamp; if the idle transaction timeout fires first, the TTID span is simply absent

---

## Implications for .NET/MAUI (Android)

* The **core mechanism** is `ViewTreeObserver.OnDrawListener` (or equivalently `FirstDrawDoneListener` from the Android Sentry SDK). This is a standard Android API accessible from .NET
* The Sentry Android SDK's `FirstDrawDoneListener` may be directly usable from the managed layer since the Android SDK is already embedded — worth checking before re-implementing
* The timing of span creation needs to align with MAUI's navigation model (not `react-native-screens` / `ScreenStackFragment`) — MAUI pages and their lifecycle events would be the equivalent hook points
* The start timestamp must be adjusted to the app start timestamp for the first screen, matching the spec requirement

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.