Observability Basics
10 examples to get you started with mobile observability - 7 basic and 3 intermediate. Covers native crashes, ANRs, JavaScript errors, and how they fit into the signal stack every Expo team ships before store release.
Search across all documentation pages
10 examples to get you started with mobile observability - 7 basic and 3 intermediate. Covers native crashes, ANRs, JavaScript errors, and how they fit into the signal stack every Expo team ships before store release.
Scaffold a blank TypeScript app. Examples use built-in APIs and patterns that work on Expo SDK 57 release builds - no vendor SDK required until you wire Sentry in a later page.
npx create-expo-app@latest MyObservabilityApp --template blank-typescript
cd MyObservabilityApp
npx expo startTooling: These examples target Expo SDK 57 (
expo~57.0.4), React Native 0.86.0, and React 19.2.3.
The mobile signal stack has four layers teams instrument in order:
| Layer | What it catches | Typical tool |
|---|---|---|
| Crash / fatal | Native SIGSEGV, fatal JS, process death | Sentry, Firebase Crashlytics |
| Error / handled | Caught API failures, boundary trips, global JS | Sentry + your logger |
| Performance | Cold start, navigation TT, API latency | Sentry transactions, OTel |
| Product analytics | Funnels, retention, feature usage | Amplitude, Segment |
Use all four. A crash-free dashboard with zero performance budgets still ships slow releases.
Mobile platforms classify failures differently - your dashboards should too.
// src/observability/taxonomy.ts
export type MobileFailureKind =
| "native_crash" // process terminated - iOS/Android native
| "js_fatal" // ErrorUtils isFatal - bundle may be unhealthy
| "js_non_fatal" // caught, reported, app continues
| "anr" // Android: main thread blocked ~5s+ (Application Not Responding)
| "handled_error"; // API 5xx surfaced in UI - still worth logging
export type FailureReport = {
kind: MobileFailureKind;
message: string;
screen?: string;
release: string; // native build: 1.4.2 (42)
otaUpdateId?: string; // expo-updates manifest id
sessionId: string;
};Related: Global Error Handlers -
ErrorUtils.setGlobalHandlerfor JS fatals
Without build identity, you cannot tie a spike to an OTA or a store build.
// src/observability/releaseContext.ts
import * as Application from "expo-application";
import * as Updates from "expo-updates";
export async function getReleaseContext() {
const nativeVersion = Application.nativeApplicationVersion ?? "unknown";
const buildNumber = Application.nativeBuildVersion ?? "unknown";
const ota = Updates.updateId ?? "embedded";
const channel = Updates.channel ?? "none";
return {
release: `${nativeVersion}+${buildNumber}`,
otaUpdateId: ota,
channel,
runtimeVersion: Updates.runtimeVersion ?? "unknown",
};
}npx expo install expo-application expo-updatesrelease should match what Sentry and your backend expect - version+build is a common shapeTie crashes, logs, and API traces to one user journey.
// src/observability/session.ts
import * as Crypto from "expo-crypto";
let sessionId: string | null = null;
export async function getSessionId() {
if (!sessionId) {
const bytes = await Crypto.getRandomBytesAsync(16);
sessionId = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
return sessionId;
}sessionId as a header (X-Session-Id) on API calls for server-side joinRoute-aware crash grouping cuts triage time dramatically.
// src/observability/useActiveRoute.ts
import { usePathname, useSegments } from "expo-router";
import { useEffect } from "react";
type RouteReporter = (route: string) => void;
export function useActiveRoute(report: RouteReporter) {
const pathname = usePathname();
const segments = useSegments();
useEffect(() => {
const route = pathname || segments.join("/") || "unknown";
report(route);
}, [pathname, segments, report]);
}// app/_layout.tsx - wire once at root
import { useCallback, useRef } from "react";
import { useActiveRoute } from "@/observability/useActiveRoute";
const currentRoute = { value: "boot" };
export default function RootLayout() {
const report = useCallback((route: string) => {
currentRoute.value = route;
// crashSdk.setTag("route", route);
}, []);
useActiveRoute(report);
return null; // Stack in real app
}checkout/paymentBreadcrumbs are a time-ordered trail - keep them small and structured.
// src/observability/breadcrumbs.ts
export type Breadcrumb = {
ts: number;
category: "navigation" | "network" | "auth" | "user" | "console";
message: string;
data?: Record<string, string | number | boolean>;
};
const MAX_BREADCRUMBS = 50;
const ring: Breadcrumb[] = [];
export function addBreadcrumb(crumb: Omit<Breadcrumb, "ts">) {
ring.push({ ...crumb, ts: Date.now() });
if (ring.length > MAX_BREADCRUMBS) ring.shift();
}
export function getBreadcrumbs() {
return [...ring];
}addBreadcrumb({
category: "network",
message: "GET /v1/feed failed",
data: { status: 503, durationMs: 4200 },
});data - hashes or booleans onlylogin_success, logout) help reproduce session bugs without storing credentialsYour dashboard needs two ingestion paths.
// src/observability/report.ts
import type { FailureReport } from "./taxonomy";
type Reporter = (report: FailureReport) => void;
export function reportJsError(
report: Reporter,
error: Error,
meta: { isFatal: boolean; screen: string; release: string }
) {
report({
kind: meta.isFatal ? "js_fatal" : "js_non_fatal",
message: error.message,
screen: meta.screen,
release: meta.release,
sessionId: "…", // from getSessionId()
});
}
// Native crashes are captured by @sentry/react-native / Crashlytics native SDKs.
// JS layer only sees them as "previous session ended unexpectedly" on next launch.componentDidCatch on sibling trees - native crashes do notProduct and engineering should agree on one north-star reliability metric.
// src/observability/metrics.ts
export type SessionOutcome = "clean" | "crashed" | "unknown";
export function crashFreeRate(sessions: SessionOutcome[]) {
const known = sessions.filter((s) => s !== "unknown");
const clean = known.filter((s) => s === "clean").length;
return known.length === 0 ? 1 : clean / known.length;
}
// Example SLO: 99.5% crash-free sessions over 28 days (see slos-for-mobile-apps)Chain the previous handler and attach release context before forwarding to your reporter.
// src/bootstrap/installObservability.ts
import { getReleaseContext } from "@/observability/releaseContext";
import { getBreadcrumbs } from "@/observability/breadcrumbs";
type GlobalHandler = (error: Error, isFatal?: boolean) => void;
declare const ErrorUtils: {
getGlobalHandler(): GlobalHandler;
setGlobalHandler(handler: GlobalHandler): void;
};
export function installGlobalObservability(
send: (payload: Record<string, unknown>) => void
) {
const previous = ErrorUtils.getGlobalHandler();
ErrorUtils.setGlobalHandler(async (error, isFatal = false) => {
const release = await getReleaseContext();
send({
kind: isFatal ? "js_fatal" : "js_non_fatal",
message: error.message,
stack: error.stack,
isFatal,
breadcrumbs: getBreadcrumbs(),
...release,
});
previous(error, isFatal);
});
}previous - dev redbox and vendor SDKs depend on itbeforeSend for consistent dashboardsRelated: Error Handling Basics - three-layer error model
ANRs come from synchronous work on the UI thread - often JSON parse, logging floods, or heavy useMemo on large arrays.
import { useEffect, useRef } from "react";
import { InteractionManager } from "react-native";
export function useMainThreadWatchdog(thresholdMs = 2000) {
const lastTick = useRef(Date.now());
useEffect(() => {
const id = setInterval(() => {
const now = Date.now();
const gap = now - lastTick.current;
lastTick.current = now;
if (gap > thresholdMs) {
console.warn("[perf] event loop gap ms", gap);
// report non-fatal: possible ANR / JS thread stall
}
}, 500);
return () => clearInterval(id);
}, [thresholdMs]);
}
// Defer heavy work off the critical path
export function deferAfterInteractions(task: () => void) {
InteractionManager.runAfterInteractions(task);
}Wire crash reporting, logging, and analytics behind one facade so screens stay clean.
// src/observability/index.ts
import { getReleaseContext } from "./releaseContext";
import { getSessionId } from "./session";
import { addBreadcrumb, getBreadcrumbs } from "./breadcrumbs";
export const observability = {
async baseContext() {
const [release, sessionId] = await Promise.all([
getReleaseContext(),
getSessionId(),
]);
return { ...release, sessionId };
},
breadcrumb: addBreadcrumb,
getBreadcrumbs,
captureException(error: Error, extra?: Record<string, unknown>) {
// forward to Sentry + log buffer
console.error("[capture]", error.message, extra);
},
track(event: string, props?: Record<string, string | number | boolean>) {
// forward to analytics with consent gate - see analytics-and-privacy
},
};// app/_layout.tsx
import { useEffect } from "react";
import { installGlobalObservability } from "@/bootstrap/installObservability";
import { observability } from "@/observability";
installGlobalObservability((payload) => {
observability.captureException(new Error(payload.message as string), payload);
});
export default function RootLayout() {
useEffect(() => {
observability.baseContext().then((ctx) => {
observability.track("app_open", { channel: ctx.channel });
});
}, []);
return null;
}baseContext() runs once per session - cache internallytrack until gates resolveexpo-updates updateId and runtimeVersion.Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 19, 2026