getsentry / getsentry/sentry-dotnet
Implement Time to Initial 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 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 iOS
Like Android, RN has two parallel native paths plus a JS fallback. All paths 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.
The iOS implementation delegates entirely to the Cocoa SDK's `SentryFramesTracker` (backed by `CADisplayLink`) rather than implementing its own frame detection.
### Path A — View-based (`RNSentryOnDrawReporter`)
A special view component (``) is embedded in the screen's component tree. When rendered with `initialDisplay=true`:
1. A `RNSentryFramesTrackerListener` is attached to `SentryFramesTracker` from the Cocoa SDK
2. `SentryFramesTracker` uses 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 the map under `"ttid-{parentSpanId}"`
### Path B — Navigation integration (`swizzleViewDidAppear` + `RNSentryFramesTrackerListener`)
Activated when `enableTimeToInitialDisplay=true` in the React Navigation integration:
1. At init time, `[RNSentryRNSScreen swizzleViewDidAppear]` is called on the main thread — this swizzles `viewDidAppear` on `react-native-screens` screen classes
2. A `RNSentryFramesTrackerListener` is created with a callback that calls `[RNSentryTimeToDisplay putTimeToInitialDisplayForActiveSpan:timestamp]`
3. JS calls `NATIVE.setActiveSpanId(spanId)` to register the current span
4. On `viewDidAppear`, the listener starts listening on `SentryFramesTracker`
5. On the next frame, the timestamp is stored under `"ttid-navigation-{activeSpanId}"`
### Fallback — `CADisplayLink` bridge (`getNewScreenTimeToDisplay`)
If neither native path produces a timestamp in time:
1. Creates a one-shot `CADisplayLink` targeting a `handleDisplayLink:` selector
2. Added to `NSRunLoop.mainRunLoop` with `NSRunLoopCommonModes`
3. On the next display link callback, captures `[NSDate date] timeIntervalSince1970` and resolves the promise, then invalidates the `CADisplayLink`
4. Only available on `TARGET_OS_IOS` — returns empty on macOS / Catalyst
### JS-Side Span Construction
Identical to Android — after the transaction ends, `processEvent` calls `NATIVE.popTimeToDisplayFor(...)`, constructs the span, and sets the `time_to_initial_display` measurement. See the Android notes for the shared JS flow.
### App Start Timestamp Alignment
Same as Android — both the transaction's `start_timestamp` and the TTID span's `start_timestamp` are overwritten with the native app start timestamp, and the measurement is recalculated.
---
## Key iOS API: `SentryFramesTracker` / `CADisplayLink`
The Cocoa SDK's `SentryFramesTracker` is the frame detection mechanism on iOS. It runs a `CADisplayLink` that fires on every rendered frame. RN taps into this via `PrivateSentrySDKOnly` — the same internal API used for slow/frozen frames and App Start data.
`SentryFramesTracker` must be running for any of this to work. In the Cocoa SDK this is controlled by `enableAutoPerformanceTracing`.
---
## Quirks and Limitations
* `SentryFramesTracker` must be active — if `enableAutoPerformanceTracing` is not set in the Cocoa SDK, all paths fail silently and no TTID is reported
* The `CADisplayLink` fallback is iOS-only (`TARGET_OS_IOS`) — it returns empty on Mac Catalyst
* `swizzleViewDidAppear` requires `react-native-screens` — without it the navigation integration path is unavailable
* `activeSpanId` is a single global — same potential span ID mismatch risk as Android (mitigated by keyed map)
* A 30-second timeout in the JS layer bounds how long it waits for a native timestamp
---
## Implications for .NET/MAUI (iOS)
* The **core mechanism** is `SentryFramesTracker` / `CADisplayLink` via `PrivateSentrySDKOnly`. The Cocoa bindings need to expose the relevant API — specifically `SentryFramesTracker` or a listener protocol (`SentryFramesTrackerListener`)
* `PrivateSentrySDKOnly.isFramesTrackingRunning` and `currentScreenFrames` already appear in `ApiDefinitions.cs` (used for slow/frozen frames), but the listener protocol for one-shot frame callbacks likely needs adding
* The equivalent of `viewDidAppear` in MAUI is a page's `OnAppearing` lifecycle event — this is a natural hook point for starting the frame listener
* The Cocoa binding additions would need to go through `scripts/patch-cocoa-bindings.cs` since `ApiDefinitions.cs` is auto-generated
* `enableAutoPerformanceTracing` must be set on the Cocoa SDK options for `SentryFramesTracker` to run
Contributor guide
Assessment
This issue has not been assessed yet.