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