getsentry / getsentry/sentry-dotnet

Implement Time to Initial Display on iOS

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

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.