getsentry / getsentry/sentry-dotnet

Implement App Start metrics on Android

Open
#5,097 1 comment 0 reactions 1 assignee Claimed by @jamescrosswell View on GitHub
.NET Feature
Dominant language
C#
Stars
770
Forks
248
Avg merge
3d 4h
Merged PRs (30d)
49

Description

# Description

Part of

* [NET-185](https://linear.app/getsentry/issue/NET-185/mobile-vitals)

When a mobile app launches, users experience a delay before anything is visible. Sentry's **App Start** feature measures this — from the earliest native process initialisation to first frame rendered — and surfaces it as a performance metric in the Sentry UI under "Mobile Vitals".

Two variants are tracked:

* **Cold Start** (`app.start.cold`): Process wasn't in memory; full initialisation from scratch
* **Warm Start** (`app.start.warm`): Process survived but Activity was destroyed; partial re-initialisation

App starts > 60 seconds are discarded as invalid.

The data is reported as:

1. A **transaction** with op `ui.load` whose start timestamp is backdated to the native app start time
2. A **measurement** on that transaction (`app_start_cold` or `app_start_warm` in milliseconds)
3. A **child span** (`app.start.cold` / `app.start.warm`) with sub-spans detailing each phase of startup

### References

* [Mobile Vitals product docs]()
* [App Starts SDK spec]()
* [React Native initial implementation (PR #1704)]()
* [React Native refactor to standalone integration (PR #3852)]()

---

## How React Native Solves It

The **native SDKs already collect the timing data** during startup (before RN even initialises) so RN just reads that data and builds the correct span structure on top of it.

### The Flow

1. At app launch, `NATIVE.fetchNativeAppStart()` is called once to retrieve timing data from the native layer. The native flag `has_fetched` is set to prevent double-reporting.
2. The returned data looks like:

```typescript
{
type: 'cold' | 'warm' | 'unknown',
app_start_timestamp_ms: number,
spans: [{ description, start_timestamp_ms, end_timestamp_ms }]
}
```
3. This data is held until the first navigation transaction starts (the first screen load). At that point:
* The transaction's start timestamp is **overwritten** to `app_start_timestamp_ms`
* An `app.start.cold` / `app.start.warm` child span is created covering the full startup window
* The native sub-spans (process init, content providers, application init, etc.) are added as children of that span
* A measurement is added: `app_start_cold` or `app_start_warm` in milliseconds
* TTID/TTFD span start timestamps are also adjusted back to match the app start time
4. A JS-specific span (`"JS Bundle Execution Before React Root"`) is also added to cover the RN-specific initialization phase.

### Android Native Sub-Spans

The Android SDK captures detailed sub-spans via `AppStartMetrics` (cold start only):

| Operation | Description |
| -- | -- |
| `process.load` | Process Initialization (process creation → class loaded) |
| `contentprovider.load` | One per ContentProvider's `onCreate` |
| `application.load` | Application's `onCreate` |
| `activity.load` | Activity's `onCreate` → first draw |

---

## How the First Navigation Transaction Is Detected

This is all handled in the **React Native JS layer**.

In `RNSentryStart.java`, RN nulls out the Android SDK's tracing configuration:

```java
options.setTracesSampleRate(null);
options.setTracesSampler(null);
```

This prevents the Android SDK from creating any transactions of its own (including its activity lifecycle auto-instrumentation). All `ui.load` transactions originate from JS.

### The React Navigation integration creates the span

When the navigation container is registered, the React Navigation integration calls `startIdleNavigationSpan()`, creating a root idle span with `op: 'navigation'` and `forceTransaction: true`. This is not yet a `ui.load` transaction — the op is rewritten later.

### The appStart integration listens passively

The appStart integration registers a `spanStart` listener during setup. When the first root span fires, it latches its `spanId` as the target via `firstStartedActiveRootSpanId`. This is a one-shot latch — subsequent spans are ignored. If the first span is later discarded (e.g. empty route change), the latch resets and the next root span becomes the target.

### App start data is fetched lazily in `processEvent`

`NATIVE.fetchNativeAppStart()` is **not** called eagerly at SDK init. It is called inside `processEvent` when the first transaction is about to be sent. At that point the appStart integration:

1. Confirms the transaction's `span_id` matches `firstStartedActiveRootSpanId`
2. Calls `NATIVE.fetchNativeAppStart()` (checks `has_fetched` flag on the native side to prevent double-reporting)
3. Rewrites `event.op` from `'navigation'` → `'ui.load'`
4. Backdates `event.start_timestamp` to the native app start timestamp
5. Adds child spans and measurements
6. Sets `appStartDataFlushed = true` to ensure this only happens once per app run

The two integrations never call each other directly — they communicate through the SDK's event pipeline (`spanStart` event and `processEvent` hook).

---

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.