references/queries.md
# EAS Observe CLI
EAS Observe collects app performance telemetry and custom events from Expo apps and exposes them through five EAS CLI commands. Pass the `--help` flag to any command for the latest API.
## Commands Overview
| Command | Purpose |
|---------|---------|
| `eas observe:metrics-summary` | Per-version statistical aggregates for app-startup performance metrics (median, p90, etc.) |
| `eas observe:metrics` | Individual performance metric samples ordered by value or timestamp (paginated) |
| `eas observe:routes` | Per-route statistical aggregates for navigation metrics (Cold TTR, Warm TTR, Nav TTI) |
| `eas observe:events` | Custom events emitted by the app via `logEvent` — name summary, all events, or filtered by event name (paginated) |
| `eas observe:versions` | App version hierarchy with build numbers, OTA update IDs, and event counts |
All five commands share these common flags:
- `--platform ios` or `--platform android` — filter by platform (default: both)
- `--start <ISO date>` and `--end <ISO date>` — explicit time range
- `--days <N>` — show data from the last N days (mutually exclusive with `--start`/`--end`)
- `--project-id <id>` — run against a specific project without needing a project directory. When passed, the command will not try to create a new EAS project where one is unneeded.
- `--json` — machine-readable output (implies `--non-interactive`)
- `--non-interactive` — fail instead of prompting
Default time range is the last 60 days when none of `--days`, `--start`, `--end` is given.
## Supported Metrics
### App-startup metrics
Used by `observe:metrics-summary` and `observe:metrics`.
| Alias | Full name | Display |
|-------|-----------|---------|
| `tti` | `expo.app_startup.tti` | Startup TTI (time to interactive) |
| `ttr` | `expo.app_startup.ttr` | Startup TTR (time to render) |
| `cold_launch` | `expo.app_startup.cold_launch_time` | Cold Launch |
| `warm_launch` | `expo.app_startup.warm_launch_time` | Warm Launch |
| `bundle_load` | `expo.app_startup.bundle_load_time` | Bundle Load |
| `update_download` | `expo.updates.download_time` | Update Download |
### Navigation metrics
Used by `observe:routes`. Measured per route name.
| Alias | Full name | Display |
|-------|-----------|---------|
| `cold_ttr` | `expo.navigation.cold_ttr` | Nav Cold TTR |
| `warm_ttr` | `expo.navigation.warm_ttr` | Nav Warm TTR |
| `nav_tti` | `expo.navigation.tti` | Nav TTI |
## `eas observe:metrics-summary`
Shows per-version statistical aggregates for one or more metrics, with separate tables per platform.
```bash
# All default metrics, last 60 days, both platforms
eas observe:metrics-summary
# Single metric
eas observe:metrics-summary --metric tti
# Multiple metrics — each renders as its own table
eas observe:metrics-summary --metric tti --metric cold_launch
# Choose which statistics to display
eas observe:metrics-summary --metric tti --stat median --stat p90 --stat eventCount
# Narrow time range and platform
eas observe:metrics-summary --metric tti --days 14 --platform ios
```
**Stat flags:** `min`, `max`, `median` (alias `med`), `average` (alias `avg`), `p80`, `p90`, `p99`, `eventCount` (alias `count`).
**Default stats:** `median` + `eventCount` in the table; all stats in JSON.
**Table layout:**
- One table per metric (with merged value + event count cells, e.g. `0.45s (150)`)
- Each table shows iOS and Android in separate sections
- App Version column includes build numbers in parentheses (e.g. `1.2.0 (42)`)
- Footer row per platform shows total events per metric
- **Update IDs are omitted from the table** to keep output readable when a version has many updates; they are included in the JSON output as an array per version
**JSON output shape:**
```json
{
"versions": [
{
"appVersion": "1.2.0",
"platform": "IOS",
"buildNumbers": ["42"],
"updateIds": ["abc-def-...", "..."],
"metrics": {
"expo.app_startup.tti": { "median": 0.45, "p90": 0.9, "...": "..." }
}
}
],
"totalEventCounts": {
"expo.app_startup.tti": { "IOS": 1234, "ANDROID": 890 }
}
}
```
## `eas observe:metrics`
Shows individual performance metric samples, paginated. The metric is a positional argument, not a flag. If omitted and running interactively, prompts for selection; in non-interactive mode it throws an error.
```bash
# Interactive: prompts for metric
eas observe:metrics
# Specify metric as positional arg
eas observe:metrics tti
# Filter by version or update, sort by slowest
eas observe:metrics tti --app-version 1.2.0 --sort slowest --limit 20
# Pagination — pass the endCursor from the previous run
eas observe:metrics tti --after <cursor>
```
**Sample-specific flags:**
- `--sort <oldest|newest|slowest|fastest>` — defaults to `oldest`
- `--limit <N>` — samples per page (default 10, max 100)
- `--after <cursor>` — pagination cursor from the previous run
- `--app-version <version>` — filter by app version string
- `--update-id <id>` — filter by EAS update ID
**Table layout:**
- Summary header shows the metric name, time range, and total sample count across all versions (e.g. `TTI samples for the last 60 days — 1,234 total events`)
- Columns: Value, App Version (with build number), Update (only when any sample has one), Platform, Device, Country, Timestamp
- When `hasNextPage` is true, prints `Next page: --after <endCursor>` hint below the table
- JSON output also includes `sessionId`, `easClientId`, and a `customParams` object per sample
## `eas observe:routes`
Shows per-route statistical aggregates for navigation metrics (Cold TTR, Warm TTR, Nav TTI), grouped by route name with separate sections per platform.
```bash
# All three navigation metrics, default stats, last 60 days, both platforms
eas observe:routes
# Single metric, last 7 days, iOS only
eas observe:routes --metric nav_tti --days 7 --platform ios
# Multiple metrics and stats
eas observe:routes --metric cold_ttr --metric warm_ttr --stat median --stat p90 --stat count
# Filter to a single build
eas observe:routes --app-version 1.2.0 --build-number 42
# Narrow to specific routes (repeat the flag for multiple routes)
eas observe:routes --route-name /new --route-name /settings
# Pagination — each platform has its own cursor; pass the relevant endCursor
eas observe:routes --after <cursor>
```
**Routes-specific flags:**
- `--metric <cold_ttr|warm_ttr|nav_tti>` — navigation metric(s) to display, can be repeated. Defaults to all three.
- `--stat <median|p90|count>` — statistic(s) per metric. Aliases: `med` → `median`, `event_count` / `eventCount` → `count`.
- `--limit <N>` — routes per page (default **50**, max **200**, different from `metrics`/`events` which default to 10).
- `--after <cursor>` — pagination cursor from the previous run.
- `--app-version <version>` — filter by app version string.
- `--build-number <number>` — filter by app build number (routes-only).
- `--route-name <name>` — filter by route name. Repeatable; only the listed routes are returned across both platforms. Duplicates are de-duplicated; omitting the flag returns all routes.
- `--update-id <id>` — filter by EAS update ID.
**Default stats:** `median` + `count` in the table; `median`, `p90`, `count` in JSON.
**Table layout:**
- Summary header with the chosen stats and time range, e.g. `Med, P90 values (navigation count) for the last 7 days`.
- Separate iOS and Android sections.
- First column is **Route**, followed by one column per metric/stat. With both display stats and `count`, cells are merged like `0.32s (1240)`.
- Each platform has its own pagination hint: `Next page (iOS): --after <endCursor>`.
**JSON output shape:**
```json
{
"routes": [
{
"routeName": "(tabs)/home",
"platform": "IOS",
"metrics": {
"expo.navigation.cold_ttr": { "median": 0.32, "p90": 0.85, "count": 1240 },
"expo.navigation.tti": { "median": 0.55, "p90": 1.10, "count": 1240 }
}
}
],
"pageInfoByPlatform": {
"IOS": { "hasNextPage": true, "endCursor": "..." },
"ANDROID": { "hasNextPage": false, "endCursor": null }
}
}
```
## `eas observe:events`
Shows custom events emitted by the app via the `logEvent` API in `expo-observe`. Behavior depends on what is passed:
| Invocation | Result |
|---|---|
| `observe:events` | Summary table of available event names with counts |
| `observe:events --all-events` | Full list of events across **all** event names |
| `observe:events <event-name>` | Full list of events filtered by that event name |
```bash
# List the available custom event names and their counts (last 60 days)
eas observe:events
# All events across all names, last 7 days, iOS only
eas observe:events --all-events --days 7 --platform ios
# Only events with the given name
eas observe:events login_failed --limit 50
# Drill into a single session
eas observe:events --all-events --session-id <session-id>
# Pagination
eas observe:events login_failed --after <cursor>
```
**Events-specific flags:**
- `--all-events` — when no event name argument is given, list all events instead of the name summary. Cannot be combined with an event name argument.
- `--session-id <id>` — filter to events from a single session (events-only)
- `--app-version <version>` — filter by app version string
- `--update-id <id>` — filter by EAS update ID
- `--limit <N>` — events per page (default 10, max 100)
- `--after <cursor>` — pagination cursor
**Table layout (event listings):**
- Summary header: `<event-name> events <time range>` or `Custom events <time range>` for `--all-events`, with a total event count when available
- Columns: Timestamp, Event (only when listing across multiple names), Severity (only when at least one event in the page has a severity), App Version (with build number), Platform, Device, Country
- `Next page: --after <endCursor>` hint below the table when there is a next page
**Empty-result helper:** if a specific event name is queried and returns no events, the command prints a yellow `No events found matching "<name>"` warning followed by the available event names + counts in the same time range — useful for fixing typos.
**Truncation note:** the event-names summary may flag `Result is truncated; not all event names are shown.` when there are more names than the server returns in a single response.
**JSON output shape (event listing):**
```json
{
"events": [
{
"id": "...",
"eventName": "login_failed",
"timestamp": "2026-...",
"sessionId": "...",
"severityNumber": 13,
"severityText": "WARN",
"properties": [{ "key": "reason", "value": "bad_password", "type": "string" }],
"appVersion": "1.2.0",
"appBuildNumber": "42",
"appUpdateId": null,
"appEasBuildId": null,
"deviceModel": "...",
"deviceOs": "iOS",
"deviceOsVersion": "17.4",
"countryCode": "US",
"environment": "production",
"easClientId": "..."
}
],
"pageInfo": { "hasNextPage": true, "endCursor": "..." }
}
```
The name-summary mode returns `{ "names": [{ "eventName": "...", "count": 123 }], "isTruncated": false }`.
## `eas observe:versions`
Shows app version hierarchy with build numbers, OTA update IDs, and event counts per version.
```bash
# Both platforms, last 60 days
eas observe:versions
# iOS only, last 14 days
eas observe:versions --days 14 --platform ios
```
No metric-related flags. Output shows separate iOS and Android tables with columns: **App Version, First Seen, Events, Users, Builds (count), Updates (count)**.
JSON output returns the full nested hierarchy with `buildNumbers[].easBuilds[]` and `updates[].easBuilds[]`, including `firstSeenAt`, `eventCount`, and `uniqueUserCount` at every level.
## Common Workflows
**"What are my app's startup times right now?"**
```bash
eas observe:metrics-summary --days 7 --stat median --stat p90
```
**"Which TTI samples were slowest this week?"**
```bash
eas observe:metrics tti --sort slowest --days 7 --limit 20
```
**"How fast are over-the-air updates downloading in the field?"**
```bash
eas observe:metrics-summary --metric update_download --days 7
```
**"Which screens are slowest to navigate to?"**
```bash
eas observe:routes --metric nav_tti --stat median --stat p90 --days 7
```
**"How does navigation perform on just the routes I care about?"**
```bash
eas observe:routes --route-name /home --route-name /checkout --days 7
```
**"What custom events is my app emitting?"**
```bash
eas observe:events --days 7
```
**"Show me every error event from one user's session."**
```bash
eas observe:events --all-events --session-id <session-id>
```
**"What versions of my app are in the field?"**
```bash
eas observe:versions
```
**"Show me metrics for a specific project without needing to be in the repo"**
```bash
eas observe:metrics-summary --project-id <uuid> --metric tti
```
**"Get JSON for scripting"**
```bash
eas observe:metrics-summary --metric tti --json --non-interactive
```
## Notes
- Requires the user to be logged in (`eas login`).
- When `--project-id` is provided, the command does not require running inside an EAS project directory; otherwise the project ID is read from the local `app.config` / `app.json`. If using this option, ensure that you are logged in as a user that has access to the specified project.
- `observe:metrics-summary` does not print update IDs in the table but still returns them in JSON for scripting or piping into other commands.
references/setup.md
# Set up EAS Observe in an existing project
EAS Observe collects app-startup performance metrics (cold launch, warm launch, bundle load, TTR, TTI) from production Expo apps. This reference summarizes the steps to add `expo-observe` to an existing project.
> Source: https://docs.expo.dev/eas/observe/get-started/ — consult this page for the latest guidance.
## SDK 55 vs SDK 56+ at a glance
The library exports differ between SDK versions. Pick the right one for the project's SDK before copying any snippet below.
| Concern | SDK 55 | SDK 56 and later |
|---|---|---|
| Root layout HOC | `AppMetricsRoot.wrap(...)` | `ObserveRoot.wrap(...)` |
| `markInteractive()` API | Global: `AppMetrics.markInteractive()` | Hook: `const { markInteractive } = useObserve()` |
| Import source | `expo-observe` | `expo-observe` (same package) |
Everything else — package name, build process, dashboard, debug-mode behavior — is the same across versions.
## Prerequisites
Before installing, confirm all of the following:
1. **An Expo account.** Sign up at [expo.dev/signup](https://expo.dev/signup) if needed.
2. **Expo SDK 55 or later.** Run `npx expo-doctor` to check, and `npx expo install --fix` to update dependencies. SDK 56+ unlocks the newer `ObserveRoot` / `useObserve` API.
3. **An EAS project.** The app must have `extra.eas.projectId` set in its app config. If not, run `eas init` to create one.
## Step 1 — Install the library
From the project root:
```sh
npx expo install --fix
npx expo install expo-observe
```
## Step 2 — Wrap the root layout
The HOC automatically measures **Time to First Render (TTR)**. Apply it to the file that exports the app's root component. The HOC name depends on the SDK version.
**SDK 55** — use `AppMetricsRoot`:
```tsx
// app/_layout.tsx
import { Stack } from 'expo-router';
import { AppMetricsRoot } from 'expo-observe';
function RootLayout() {
return <Stack />;
}
export default AppMetricsRoot.wrap(RootLayout);
```
**SDK 56 and later** — use `ObserveRoot`:
```tsx
// app/_layout.tsx
import { Stack } from 'expo-router';
import { ObserveRoot } from 'expo-observe';
function RootLayout() {
return <Stack />;
}
export default ObserveRoot.wrap(RootLayout);
```
**Without Expo Router** (`App.tsx`): wrap the default-exported `App` component the same way — `export default AppMetricsRoot.wrap(App);` on SDK 55, or `export default ObserveRoot.wrap(App);` on SDK 56+.
## Step 3 — Mark the app as interactive
TTI is **not** collected automatically. Signal it once the screen is genuinely ready for the user — i.e. after splash-screen-blocking work like update checks, authentication, initial data fetching, or splash animations finishes. Place the call in a `useEffect` that runs once that work resolves.
**SDK 55** — call the global `AppMetrics.markInteractive()`:
```tsx
// app/_layout.tsx
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { AppMetrics, AppMetricsRoot } from 'expo-observe';
import { useEffect, useState } from 'react';
SplashScreen.preventAutoHideAsync();
function RootLayout() {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
async function prepare() {
try {
await authenticateUser();
await fetchInitialData();
} catch (e) {
console.warn(e);
} finally {
setIsReady(true);
}
}
prepare();
}, []);
useEffect(() => {
if (isReady) {
SplashScreen.hide();
AppMetrics.markInteractive();
}
}, [isReady]);
if (!isReady) return null;
return <Stack />;
}
export default AppMetricsRoot.wrap(RootLayout);
```
**SDK 56 and later** — use the `useObserve()` hook to get a bound `markInteractive`:
```tsx
// app/_layout.tsx
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { ObserveRoot, useObserve } from 'expo-observe';
import { useEffect, useState } from 'react';
SplashScreen.preventAutoHideAsync();
function RootLayout() {
const [isReady, setIsReady] = useState(false);
const { markInteractive } = useObserve();
useEffect(() => {
async function prepare() {
try {
await authenticateUser();
await fetchInitialData();
} catch (e) {
console.warn(e);
} finally {
setIsReady(true);
}
}
prepare();
}, []);
useEffect(() => {
if (isReady) {
SplashScreen.hide();
markInteractive();
}
}, [isReady, markInteractive]);
if (!isReady) return null;
return <Stack />;
}
export default ObserveRoot.wrap(RootLayout);
```
**Without Expo Router:** the structure is the same in `App.tsx`. Use `SplashScreen.hideAsync()` instead of `SplashScreen.hide()` and replace `<Stack />` with the app's root tree.
### Multiple entry screens
`markInteractive()` is safe to call repeatedly — only the **first** call per session is recorded. If the app has more than one entry screen (onboarding, login, deep-link targets), call `markInteractive()` on **each one**. Otherwise TTI will be missing for sessions that open via a deep link to a screen without the call.
## Step 4 — Build the app
Metrics are collected from real builds, not from `expo start`:
```sh
eas build
```
> By default, metrics collected from **debug builds** are not dispatched. A build is treated as a debug build when either the native app is a debug build or the JS bundle is a development bundle (`__DEV__` is `true`). To dispatch anyway while testing the integration, set `dispatchInDebug: true` when calling `configure()` — see [Enable metrics in development](https://docs.expo.dev/eas/observe/configuration/#enable-metrics-in-development). This has no effect on release builds.
## Step 5 — View the metrics
Open the **Observe** tab in the EAS dashboard at `https://expo.dev/accounts/[account]/projects/[project]/observe` to view metrics from the app.
To query metrics from the terminal with the EAS CLI, see [`./queries.md`](./queries.md). For interpreting the metrics themselves, see [`./metrics.md`](./metrics.md).
## Optional — per-route navigation metrics (SDK 56+)
By default `expo-observe` records app-wide startup metrics only. To additionally get **per-route / per-screen** navigation metrics (`cold_ttr`, `warm_ttr`, and a per-navigation `tti`, each tagged with the route/screen), enable one of the navigation integrations. These require **SDK 56 or later**; on earlier SDKs they are silent no-ops. Query the resulting data with `eas observe:routes` (see [`./queries.md`](./queries.md)).
Pick the integration that matches the app's router:
### Expo Router
Docs: https://docs.expo.dev/eas/observe/integrations/expo-router/
1. Enable the integration at module scope, **before any screen mounts** (it cannot be toggled at runtime — calling `configure()` after mount throws):
```tsx
// app/_layout.tsx
import { Observe } from 'expo-observe';
Observe.configure({
integrations: { 'expo-router': true },
});
```
2. Call `useObserve()` inside each screen to get a `markInteractive` scoped to the current route, and call it from a `useEffect` once the screen is interactive:
```tsx
import { useObserve } from 'expo-observe';
import { useEffect } from 'react';
export default function Home() {
const { markInteractive } = useObserve();
useEffect(() => {
markInteractive();
}, [markInteractive]);
return (/* screen content */);
}
```
Events are tagged with the route **pattern** (e.g. `/(tabs)/sessions/[sessionId]`) so the dashboard buckets distinct param values together; the resolved `url` and `routeParams` are also included. Requires `expo-router` installed at runtime, or the integration no-ops.
### React Navigation
Docs: https://docs.expo.dev/eas/observe/integrations/react-navigation/
Requires `@react-navigation/native` 7.0.0 or later. Same `useObserve()` screen usage as above, plus **two** extra changes:
1. Enable the integration at module scope, before mount:
```tsx
// App.tsx
import { Observe } from 'expo-observe';
Observe.configure({
integrations: { 'react-navigation': true },
});
```
2. Replace the top-level `<NavigationContainer>` with `<ObserveNavigationContainer>` — a drop-in replacement that accepts the same props and forwards the same ref. If you pass a `linking` config it is used to resolve a human-readable screen path; otherwise the metric falls back to `route.name`.
```tsx
import { ObserveNavigationContainer } from 'expo-observe/integrations/react-navigation';
export default function App() {
return <ObserveNavigationContainer>{/* navigators */}</ObserveNavigationContainer>;
}
```
In both integrations, `useObserve()` is safe to leave in place even when the integration is disabled or the router package is absent — it falls back to the global `markInteractive`.
## Optional — user-defined events (SDK 56+)
Beyond the automatic startup and navigation metrics, you can record your own named events from anywhere in the app to track product moments — a completed onboarding, an exported report, a selected item. Use `Observe.logEvent(name, options?)`.
> Source: https://docs.expo.dev/eas/observe/events/ — consult this page for the latest guidance.
```tsx
import { Observe } from 'expo-observe';
function handleOnboardingComplete() {
Observe.logEvent('onboarding.completed');
}
```
`logEvent` is a plain function call — it is **not** a hook and needs no `useObserve()`. Call it from event handlers, effects, or any non-render code. The event is persisted on-device and dispatched on the next flush (see dispatch notes below); the call returns immediately and never blocks the UI.
### Parameters
| Parameter | Type | Required | Notes |
|---|---|---|---|
| `name` | `string` | yes | Stable, dot-separated identifier, e.g. `'report.exported'`. |
| `options.attributes` | `Record<string, string \| number \| boolean \| array \| nested object>` | no | Structured context attached to the event. |
| `options.body` | `string` | no | Free-form message complementing the structured attributes. |
| `options.severity` | `'trace' \| 'debug' \| 'info' \| 'warn' \| 'error' \| 'fatal'` | no | Defaults to `'info'`. |
**Attributes** are the primary way to make an event queryable — attach the identifiers and measurements you'll want to filter or break down by:
```tsx
Observe.logEvent('report.exported', {
attributes: {
format: 'csv',
rowCount: 1248,
durationMs: 532,
filters: ['status:active', 'region:us-west'],
},
});
```
**Severity and body** may be used for operational events you may want to triage by level:
```tsx
Observe.logEvent('cache.evicted', {
body: 'Cache evicted because disk pressure exceeded the configured threshold.',
severity: 'warn',
attributes: { evictedItemCount: 42, freedBytes: 1048576 },
});
```
### Naming and privacy
- **Use lowercase, dot-separated names** (`task.completed`, `onboarding.skipped`). Keep them stable — `report_exported` and `report.exported` bucket as two separate events in the dashboard.
- **Never put PII in event names, attribute keys, or attribute values.** Everything is transmitted off-device and visible in the dashboard.
### Dispatch
User-defined events are persisted on-device, batched, and dispatched on the next flush as **OpenTelemetry log records** — the same delivery path and timing as other metrics. The debug-build caveat from [Step 4](#step-4--build-the-app) applies unchanged: debug builds don't dispatch unless `configure({ dispatchInDebug: true })` is set.
### Viewing events
User-defined events appear under the **Events** tab in the Observe dashboard, and are queryable from the terminal with `eas observe:events` — see [`./queries.md`](./queries.md).
## Quick checklist
- [ ] SDK ≥ 55, EAS project linked.
- [ ] `expo-observe` installed via `npx expo install`.
- [ ] Root component exported through `AppMetricsRoot.wrap(...)` (SDK 55) or `ObserveRoot.wrap(...)` (SDK 56+).
- [ ] `markInteractive()` called from every entry screen once it is genuinely interactive — global `AppMetrics.markInteractive()` on SDK 55, or `useObserve()` hook on SDK 56+.
- [ ] (Optional, SDK 56+) Per-route metrics enabled via `Observe.configure({ integrations: { ... } })`, plus `<ObserveNavigationContainer>` for React Navigation.
- [ ] (Optional, SDK 56+) User-defined events emitted via `Observe.logEvent(name, { attributes })` with stable, lowercase, dot-separated names and no PII.
- [ ] New build produced with `eas build` and metrics visible in the Observe dashboard.