AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
EAS Observe is an Expo Application Services tool designed to track application performance, including startup times, navigation metrics, and custom events. It also provides robust error and crash reporting capabilities. By integrating the library into an Expo project, developers can gain visibility into real-world app behavior, helping to identify bottlenecks, diagnose slow startups, and resolve runtime issues. The service includes CLI tools for querying metrics and supports various integrations with core Expo libraries to provide deeper insights into image loading and animation performance.
What it does
- Tracks startup performance metrics like cold and warm launch times.
- Measures time-to-interactive and navigation performance.
- Captures JavaScript errors and native crashes with symbolicated stack traces.
- Supports user-defined events for custom performance tracking.
- Provides CLI commands to query and analyze performance data.
- Integrates with Expo Router, React Navigation, and other libraries for contextual metrics.
When to use it
- When you need to monitor production app performance and startup speed.
- When you want to track and debug runtime errors and native crashes.
- When investigating performance regressions across different devices or network conditions.
- When you need to measure the impact of specific user flows or navigation patterns.
Advantages
- Provides actionable insights into real-world user experience.
- Helps distinguish between code-related slowness and environmental throttling.
- Simplifies error tracking with integrated crash reporting.
- Offers granular control over data sampling and reporting configuration.
Usage examples
- “How do I add EAS Observe to my Expo project?”
- The agent will guide you through installing expo-observe, wrapping your root layout with ObserveRoot, and configuring the necessary integrations.
- “How can I check my app's cold launch performance from the terminal?”
- The agent will provide the appropriate eas observe:metrics-summary command to retrieve and display your application's startup metrics.
- “What does it mean if my app has high TTI but low totalDelay?”
- The agent will explain that this indicates a slow but smooth launch, suggesting you should look into bundle size or data-fetch waterfalls.
- “How do I enable the Reanimated integration for EAS Observe?”
- The agent will show you how to update your Observe.configure call to include the reanimated integration.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/expo/eas-observe/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip eas-observe.zip -d .agents/skills
Documentation by Expo
EAS Observe
EAS service - costs apply. EAS Observe is an Expo Application Services product. The free EAS plan allows up to 10,000 monthly active users, with a limited set of features; higher usage requires a paid subscription. For details, see https://expo.dev/pricing#plan-features.
EAS Observe tracks startup, navigation, and custom-event performance from production Expo apps, and records JavaScript errors and native crashes. It needs a development or production build — the native library is not in Expo Go.
Source of truth: https://docs.expo.dev/eas/observe/ — always consult the canonical docs when API details matter, especially get-started, configuration, integrations, and the metrics reference. EAS Observe is evolving; this skill's references are written to stay accurate but may lag the docs.
Which reference to read
The five reference files in ./references/ cover what people typically need this skill for:
Adding EAS Observe to a project →
./references/setup.md. Install, wrap the root layout (AppMetricsRooton SDK 55,ObserveRooton SDK 56+), mark the app interactive (globalmarkInteractive()on SDK 55, theuseObserve()hook or<ObserveInteractiveMarker />on SDK 56+), optional per-route navigation metrics through the Expo Router / React Navigation integrations, user-defined events viaObserve.logEvent(SDK 56+), error reporting, and runtime configuration (sampling, dispatch, environments, custom endpoint).Querying metrics from the terminal →
./references/queries.md. Theeas observe:*commands —metrics-summary,metrics,routes,events,session,versions,errors,event— with flags, metric aliases, table layouts, JSON shapes, and common workflows. Also the matchingobserve_*tools in the Expo MCP Server, and how their inputs differ from the CLI flags.Reading a dashboard or CLI output →
./references/metrics.md. Target thresholds per metric, what the automatic TTI params mean (frameRate.*,device.*,network.*), and diagnostic patterns for telling slow-but-smooth startup apart from main-thread contention, hard blocks, or throttled devices.Reporting problems from library integrations →
./references/library-integrations.md. The current and future integrations that report problems a library detects as events or errors, with their options, limits, and how to query the reports. Currently:expo-image(expo-image.oversizedevents for images decoded far larger than the screen) andreact-native-reanimated(reanimated.errorerrors and optionalreanimated.warningevents).Shipping an Observe integration in a library →
./references/third-party.md. For package authors only (SDK 57+): optional peer dependency, config declaration merging,Observe.registerIntegration(), and event naming.
Quick links to the docs
Get started: https://docs.expo.dev/eas/observe/get-started/
Dashboard guide: https://docs.expo.dev/eas/observe/dashboard/
Querying with EAS CLI: https://docs.expo.dev/eas/observe/eas-cli/
Querying with the Expo MCP Server: https://docs.expo.dev/eas/observe/mcp/
Metrics reference: https://docs.expo.dev/eas/observe/reference/metrics/
Expo Router integration: https://docs.expo.dev/eas/observe/integrations/expo-router/
React Navigation integration: https://docs.expo.dev/eas/observe/integrations/react-navigation/
Expo Image integration: https://docs.expo.dev/eas/observe/integrations/expo-image/
Reanimated integration: https://docs.expo.dev/eas/observe/integrations/react-native-reanimated/
User-defined events: https://docs.expo.dev/eas/observe/events/
Error reporting: https://docs.expo.dev/eas/observe/errors/
Configuration: https://docs.expo.dev/eas/observe/configuration/
Third-party integrations: https://docs.expo.dev/eas/observe/integrations/third-party/
EAS Update download performance: https://docs.expo.dev/eas/observe/eas-update/
Troubleshooting: https://docs.expo.dev/eas/observe/reference/troubleshooting/
Submitting Feedback
If you encounter errors, misleading or outdated information in this skill, report it so Expo can improve:
npx --yes @expo/agent-cli@latest feedback --category skills --subject "eas-observe" "<actionable feedback>"Only submit when you have something specific and actionable to report. Include as much relevant context as possible.
If an agent repeatedly fails at an Expo task or the user has to step in, submit a brief report of the task, expected result, and what went wrong using --category evals and the relevant Expo package or command as --subject.
Files
- ATTRIBUTION.mdAdded by AutoGineer809 B
- LICENSELicence file added by AutoGineer1.1 KiB
- SKILL.md5.7 KiB
- agents/openai.yaml449 B
- references/library-integrations.md6.8 KiB
- references/metrics.md8.5 KiB
- references/queries.md22.5 KiB
- references/setup.md27.4 KiB
- references/third-party.md4.6 KiB
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:
An Expo account. Sign up at expo.dev/signup if needed.
Expo SDK 55 or later. Run
npx expo-doctorto check, andnpx expo install --fixto update dependencies. SDK 56+ unlocks the newerObserveRoot/useObserveAPI.An EAS project. The app must have
extra.eas.projectIdset in its app config. If not, runeas initto create one.A development build or a production build.
expo-observeis a native library, so it does not work in Expo Go. Testing the integration requires a development build.
Step 1 — Install the library
From the project root:
npx expo install --fix
npx expo install expo-observeStep 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:
// 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:
// 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():
// 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:
// 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.
Custom params and route name
markInteractive() accepts an options object. Use params to slice TTI by an app-specific dimension (cohort, tenant, feature-flag variant, cache hit). Use routeName to override the route the event is tagged with — useful when the logical screen name differs from the router path, when the route is dynamic, or when the app does not use Expo Router. Param values may be strings, numbers, booleans, or any JSON-serializable value.
// SDK 56+: const { markInteractive } = useObserve();
// SDK 55: AppMetrics.markInteractive({ ... })
markInteractive({
routeName: '/feed',
params: { tenant: 'acme', cohort: 'beta', cacheHit: true },
});Declarative alternative: <ObserveInteractiveMarker /> (SDK 56+)
Instead of calling markInteractive() from an effect, render the marker component at the point the screen becomes interactive. It renders nothing and calls markInteractive() once on mount.
import { ObserveInteractiveMarker } from 'expo-observe';
function Feed({ items }) {
if (!items) return <Spinner />;
return (
<>
<FeedList items={items} />
<ObserveInteractiveMarker params={{ cacheHit: true }} />
</>
);
}The marker fires once, on mount, so its params are read from the first render. Changing them later has no effect and logs a development warning. When the params are only known later, call useObserve().markInteractive(...) imperatively instead.
Step 4 — Build the app
Metrics are collected from real builds, not from expo start:
eas buildBy 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__istrue). To dispatch anyway while testing the integration, setdispatchInDebug: truewhen callingconfigure()— see 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. For interpreting the metrics themselves, see ./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, or with observe:metrics / observe:metrics-summary under the CLI aliases nav_cold_ttr, nav_warm_ttr, and nav_tti (see ./queries.md).
Pick the integration that matches the app's router:
Expo Router
Docs: https://docs.expo.dev/eas/observe/integrations/expo-router/
Enable the integration at module scope, before any screen mounts (it cannot be toggled at runtime — calling
configure()after mount throws):// app/_layout.tsx import { Observe } from 'expo-observe'; Observe.configure({ integrations: { 'expo-router': true }, });Call
useObserve()inside each screen to get amarkInteractivescoped to the current route, and call it from auseEffectonce the screen is interactive: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.
router.prefetch() does not count as a user navigation and never seeds a cold_ttr or warm_ttr measurement. The next user-driven navigation to a prefetched route emits warm_ttr, because the screen already rendered.
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:
Enable the integration at module scope, before mount:
// App.tsx import { Observe } from 'expo-observe'; Observe.configure({ integrations: { 'react-navigation': true }, });Connect the integration to the navigation tree. Which component you use depends on whether the app uses React Navigation's dynamic or static configuration. Both record the same per-screen metrics.
Dynamic configuration — replace the top-level
<NavigationContainer>with<ObserveNavigationContainer>. It is a drop-in replacement that accepts the same props and forwards the same ref. If you pass alinkingconfig it is used to resolve a human-readable screen path; otherwise the metric falls back toroute.name.import { ObserveNavigationContainer } from 'expo-observe/integrations/react-navigation'; export default function App() { return <ObserveNavigationContainer>{/* navigators */}</ObserveNavigationContainer>; }Static configuration —
createStaticNavigation()renders the container for you, so there is nothing to replace. Create the navigation ref yourself, pass it to both<Navigation>and<ObserveNavigationProvider>, and the provider records the same timings by listening through that ref.import { createStaticNavigation, useNavigationContainerRef } from '@react-navigation/native'; import { ObserveNavigationProvider } from 'expo-observe/integrations/react-navigation'; const Navigation = createStaticNavigation(RootStack); export default function App() { const navigationRef = useNavigationContainerRef(); return ( <ObserveNavigationProvider navigationRef={navigationRef}> <Navigation ref={navigationRef} /> </ObserveNavigationProvider> ); }ObserveNavigationProviderrenders no container of its own, but it must be an ancestor of every screen souseObserve()works inside them. Pass the same ref to both elements; a ref that is not connected to a container emits no metrics.
With React Navigation v7's default lazy: true, unfocused tabs stay unmounted, so their first focus records as cold_ttr rather than warm_ttr.
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.
Per-route event params
Navigation events carry these params in addition to any custom ones passed to markInteractive:
| Param | Type | Notes |
|---|---|---|
routeName | string | Route pattern (/(tabs)/sessions/[sessionId]) with Expo Router, route-name path (/Tabs/Sessions) with React Navigation. Never contains param values. |
url | string | Resolved pathname. Expo Router only. |
routeParams | object | Resolved route params, e.g. { sessionId: 'abc' }. |
isAppLaunch | boolean | cold_ttr only. true when measured from process start rather than from a navigation action. |
urlHidden | boolean | Present as true when a filtered param caused url to be omitted. See below. |
Filter sensitive params (SDK 57+)
By default the integrations export the resolved URL and every serializable route and query param. If any of those carry sensitive values, list their keys in filteredParams:
Observe.configure({
integrations: {
'expo-router': { filteredParams: ['userId', 'token'] },
// or: 'react-navigation': { filteredParams: ['userId', 'token'] },
},
});Filtered keys are removed from routeParams, and the event drops url in favor of urlHidden: true. routeName is unaffected, because it is a pattern and never contains param values.
Common integration mistakes
Calling
Observe.configure()after mount, or toggling an integration flag mid-session, throws. Enable it at module scope.Calling markInteractive on unmounted screenorNo metadata available for the current screenmeans the call ran outside a screen component or after unmount. Move it into auseEffectinside the screen component.Call
useObserve()inside the screen component, not in a wrapper above it. A screen identity that changes between renders logs a warning.With React Navigation,
markInteractive()records only once the screen is focused. A call on an unfocused screen updates internal state but emits nottievent until focus.
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.
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. Other JS values (Date, undefined, functions) are dropped. |
options.body | string | no | Free-form message complementing the structured attributes. |
options.severity | 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | no | Defaults to 'info'. |
options.displayName | string | no | Human-friendly label, e.g. 'Onboarding completed'. Used only in the dashboard's session timeline view; grouping still keys off name. |
Attributes are the primary way to make an event queryable — attach the identifiers and measurements you'll want to filter or break down by:
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:
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_exportedandreport.exportedbucket 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 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.
Optional — error and crash reporting
expo-observe records JavaScript errors and native crashes alongside the performance metrics. Errors are persisted on-device, batched, and dispatched on the next flush. They appear on the Observe > Errors page of the dashboard. Docs: https://docs.expo.dev/eas/observe/errors/
Error reporting is in preview and requires SDK 57+. Native crashes additionally require
expo-observe57.0.21+.
JavaScript errors arrive by three paths.
1. Unhandled JS errors — automatic. A global ErrorUtils handler is installed when the package is first imported, which is earlier than any configure() call, so errors thrown before configuration are still recorded. React Native's own behavior is unchanged: the red box in development and fatal termination in production still happen. Turn the recording off with configure({ errorHandlingEnabled: false }). This only affects unhandled JS errors — boundary errors, reportError calls, and native crashes are still recorded.
2. Render-phase errors — <ObserveErrorBoundary>. Without a boundary, a render error is recorded by the global handler as an unhandled error. A boundary records it with the React component stack and then renders fallback in place of the subtree.
import { ObserveErrorBoundary } from 'expo-observe';
<ObserveErrorBoundary
fallback={({ error, resetError }) => <ErrorScreen error={error} onRetry={resetError} />}>
<Feed />
</ObserveErrorBoundary>;fallback is required and accepts a React element, null, or a function receiving { error, resetError }. resetError() clears the caught error and re-mounts the children, so they restart from a clean state. There is no capture-and-rethrow mode — a boundary always renders one of the three fallback forms. Errors that no boundary catches still reach the global handler.
For an app-wide boundary, pass errorBoundaryFallback to ObserveRoot. ObserveRoot.wrap(Component) takes no options, so replace export default ObserveRoot.wrap(RootLayout) with an outer wrapper that keeps RootLayout beneath ObserveRoot:
export default function ObservedRootLayout() {
return (
<ObserveRoot errorBoundaryFallback={<FallbackScreen />}>
<RootLayout />
</ObserveRoot>
);
}Keep your existing RootLayout beneath ObserveRoot when making this change. Rendering <ObserveRoot> inside a layout is fine only when that layout just returns the wrapper and its children.
ObserveRoot.wrap(RootLayout) stays correct when no custom boundary is needed. Without errorBoundaryFallback, no root boundary is mounted and render errors are recorded by the global handler without the component stack. Pass errorBoundaryFallback={null} to capture render errors and render nothing after an error.
3. Handled errors — Observe.reportError(error). Report failures your code caught and recovered from, which reach neither the global handler nor a boundary.
try {
await syncCart();
} catch (error) {
Observe.reportError(error);
}The value is normalized before it is sent: an Error contributes name, message, and stack; any other thrown value (a string, a plain object, a number) is stringified into the message with no stack. reportError never throws — it is called from a catch block, so a failure inside it must not turn a handled error into an unhandled one. Keep PII out of error messages; everything reported leaves the device and is visible in the dashboard.
Native crashes — automatic
With expo-observe 57.0.21+, native crashes are recorded with no setup. The report is written on the device and dispatched on the next launch, so a recent crash can take time to appear. They show with the Native source in the dashboard.
Android: uncaught Java/Kotlin exceptions, including the
Caused bychain. On Android 11+, also the OS crash records for native-code crashes such asSIGSEGVandSIGABRT.iOS: MetricKit crash reports — Mach exceptions (
EXC_BAD_ACCESS,EXC_BREAKPOINT) and Unix signals (SIGSEGV,SIGBUS,SIGTRAP). On iOS 17+, also uncaught Objective-C and Swift exceptions.Not recorded: tvOS, the iOS Simulator, ANRs, and out-of-memory terminations.
Symbolicated stack traces
Production JS stack traces point into the minified bundle unless a source map is stored for the code that threw. Errors from the embedded bundle use the build's source map; errors from an OTA bundle use the update's source map. Upload both.
Builds — set uploadSourceMaps in the build profile:
{ "build": { "production": { "uploadSourceMaps": true } } }Requires EAS CLI 22.0.0+ and a build on EAS Build servers.
eas build --localdoes not upload source maps.sourcesContentis stripped before upload; only file names and mappings are stored. A failed upload only warns — the build still completes.
EAS Updates — publish with --upload-source-maps (EAS CLI 24.9.0+):
eas update --channel production --upload-source-mapsUploads the map
expo exportemits for each native platform, withsourcesContentstripped.Maps over the 5 MiB upload limit are skipped with a warning; publishing never fails because of a source map.
The error detail page links the update that supplied the map next to Stack trace, as it does for the build.
Not available yet: native symbolication (no dSYM or ProGuard mapping upload). Native frames show symbol names on iOS and class/method/line on Android.
If the user needs ANR/OOM capture or native symbolication today, a separate crash reporter such as Sentry is still the option for that gap.
Optional — runtime configuration
Observe.configure() controls collection and dispatch. Call it at module scope, before mount. The navigation integration flags (expo-router, react-navigation) cannot be toggled at runtime. The react-native-reanimated integration can be turned off by a later configure() call (see ./library-integrations.md).
| Option | Type | Default | What it does |
|---|---|---|---|
environment | string | process.env.NODE_ENV, falling back to 'production' | Metadata tag used to group metrics; filterable in the dashboard. Does not gate dispatch. |
dispatchingEnabled | boolean | true | Master switch. While false, pending metrics are dropped, not queued. |
dispatchInDebug | boolean | false | Dispatch metrics collected from debug builds. No effect on release builds. |
sampleRate | number | undefined (all installations) | Fraction of installations that dispatch, in [0, 1]. |
errorHandlingEnabled | boolean | true | Record unhandled JS errors as exception events. |
integrations | object | undefined | Opt in to expo-router, react-navigation, expo-image, react-native-reanimated, or a third-party integration. For expo-image and react-native-reanimated, see ./library-integrations.md. |
Network request monitoring is automatic and has no off switch. expo-observe observes URLSession traffic on iOS and OkHttpClient traffic on Android from launch, and attaches a rollup of the launch window to the TTI event — including the host of the slowest request (see ./metrics.md). Observe's own uploads are excluded. No configure() option disables it as of expo-observe 57.0.9, so treat request hosts as data that leaves the device.
Sampling is deterministic per installation: an installation is permanently in-sample or out-of-sample for a given rate, so the slice is stable across launches rather than a random subset of sessions. Out-of-sample installations drop pending metrics instead of accumulating them, so lowering the rate later does not retroactively send earlier sessions. Values outside [0, 1] are clamped. Sampling depends on dispatchingEnabled.
Manual flush. Events dispatch automatically when the app backgrounds — on Android through a background worker once connectivity returns, on iOS when the app resigns active or is about to terminate. Call await Observe.dispatchEvents() to flush early, which is mainly useful while testing.
Custom endpoint. EAS Observe sends OTLP over HTTP with a JSON payload, posting to <endpointUrl>/<project-id>/v1/metrics and <endpointUrl>/<project-id>/v1/logs. Point it at any OpenTelemetry-compatible backend or collector through the app config, not configure():
{ "expo": { "extra": { "eas": { "observe": { "endpointUrl": "https://your-endpoint.com" } } } } }The URL is baked into the native layer at build time, so run npx expo prebuild and rebuild after changing it. If the backend expects standard OTLP paths without the project-ID prefix, route through a collector.
Quick checklist
[ ] SDK ≥ 55, EAS project linked, development or production build (not Expo Go).
[ ]
expo-observeinstalled vianpx expo install.[ ] Root component exported through
AppMetricsRoot.wrap(...)(SDK 55) orObserveRoot.wrap(...)(SDK 56+).[ ]
markInteractive()called from every entry screen once it is genuinely interactive — globalAppMetrics.markInteractive()on SDK 55, oruseObserve()hook on SDK 56+.[ ] (Optional, SDK 56+) Per-route metrics enabled via
Observe.configure({ integrations: { ... } }), plus<ObserveNavigationContainer>(dynamic) or<ObserveNavigationProvider>(static) for React Navigation.[ ] (Optional, SDK 57+) Sensitive route/query params listed in
filteredParams.[ ] (Optional, SDK 56+) User-defined events emitted via
Observe.logEvent(name, { attributes })with stable, lowercase, dot-separated names and no PII.[ ] (Optional) Library problem reports enabled via
integrations: { 'expo-image': true }(SDK 57+) orintegrations: { 'react-native-reanimated': true }(SDK 58+, Reanimated 4.7.0+), with noconfigureReanimatedLoggercall in the app. See./library-integrations.md.[ ] (Optional, SDK 57+)
<ObserveErrorBoundary>(orerrorBoundaryFallbackonObserveRoot) around risky subtrees,Observe.reportErrorin recovery paths,"uploadSourceMaps": truein theeas.jsonbuild profile, andeas update --upload-source-mapswhen publishing updates.[ ] New build produced with
eas buildand metrics visible in the Observe dashboard.
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs an MCP server, Needs Node.js
Security
What our scanner noticed· Low risk
- Instructions that use the network
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- Expo
- Publisher
- Expo (Official)
- Source
- expo/skills · plugins/expo/skills/eas-observe · commit a9e5bd3
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from plugins/expo/LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Expo or Expo.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download eas-observe.zip