React Native (TypeScript)
fetch, install_id in the Keychain and AsyncStorage, an offline queue, and sessions driven by AppState on iOS and Android.
A recipe for React Native 0.7x+ in TypeScript, on iOS and Android. Networking is the built-in
fetch, the queue is @react-native-async-storage/async-storage, install_id on iOS is
react-native-keychain, the app version and device model come from react-native-device-info,
and UUIDs come from uuid with the react-native-get-random-values polyfill. The code is split
into three modules: install_id, an offline queue with sending, and a session tracker.
npm install @react-native-async-storage/async-storage react-native-keychain \
react-native-device-info react-native-get-random-values uuid
cd ios && pod installImport the polyfill on the first line of the index.js entry point:
import "react-native-get-random-values";. Without it, uuid can't find crypto.getRandomValues.
In an Expo project, the same jobs are done by expo-secure-store (the Keychain on iOS),
expo-application (version and build number), expo-device (device model and type), and
expo-crypto (randomUUID() instead of uuid); AsyncStorage works in Expo too.
Where to subscribe to AppState, and why not in a screen, is covered in
Where to initialize analytics.
A React Native app is one app in appmetrica.online with iOS and Android platforms, not two apps.
Each platform has its own key on the Integration tab: the same JS bundle picks the key and
context.platform from Platform.OS at runtime. A person with the same user_id on an iPhone and
an Android tablet is counted once, and reports are split by platform — see
Event platform. Instead of two keys, you can issue a single "Any platform" key:
the recipe sends context.platform with every event anyway.
install_id
install_id is created on first launch and never changes. On iOS it's stored in the Keychain, as
in the iOS recipe: a Keychain item survives deleting and
reinstalling the app. On Android it's stored in AsyncStorage: Auto Backup keeps its database, so
after a reinstall restored from a backup, install_id stays the same, as in the
Android recipe.
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Platform } from "react-native";
import * as Keychain from "react-native-keychain";
import { v4 as uuidv4 } from "uuid";
const SERVICE = "online.appmetrica.install-id";
const STORAGE_KEY = "am_install_id";
let cached: Promise<string> | null = null;
// Reads install_id or creates it on first launch. The promise is cached per process.
export function installId(): Promise<string> {
cached ??= Platform.OS === "ios" ? fromKeychain() : fromStorage();
return cached;
}
async function fromKeychain(): Promise<string> {
const saved = await Keychain.getGenericPassword({ service: SERVICE });
if (saved) return saved.password;
const id = uuidv4();
await Keychain.setGenericPassword("install_id", id, {
service: SERVICE,
// As in the iOS recipe: readable on background launches, never moves to a new device.
accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
});
return id;
}
async function fromStorage(): Promise<string> {
const saved = await AsyncStorage.getItem(STORAGE_KEY);
if (saved) return saved;
const id = uuidv4();
await AsyncStorage.setItem(STORAGE_KEY, id);
return id;
}If the app has its own backup rules (android:fullBackupContent or
android:dataExtractionRules), don't exclude the AsyncStorage database from them.
Offline queue and sending
queue.ts stores events in AsyncStorage and sends them in batches of up to 500. The UI never waits
on the network: an event is written to disk first, and sending happens in the background. How to
handle each response follows Errors and retries:
200— remove the sent events, except those rejected withretryable: true: retry those in a minute;400,413,415— drop the batch: retrying won't help;401,403— stop sending until the app restarts: the key was revoked or the app is archived;429,503— retry afterRetry-After;- anything else and network errors — retry with a backoff from 1 s to 300 s and ±20% jitter.
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Platform } from "react-native";
const ENDPOINT = "https://i.analytics.example.com/v1/events";
// Each platform of the app has its own key: the code picks it by OS at runtime.
const WRITE_KEY = Platform.select({
ios: "pk_…", // iOS platform key from the Integration tab
android: "pk_…", // Android platform key from the Integration tab
default: "",
});
const QUEUE_KEY = "am_queue";
const MAX_BATCH = 500;
const MAX_AGE_MS = 7 * 24 * 3600 * 1000;
const TIMEOUT_MS = 15_000;
export type AnalyticsEvent = {
insert_id: string;
name: "session_start" | "session_end";
timestamp: string;
install_id: string;
session_id: string;
user_id?: string;
duration_ms?: number;
context: Record<string, string>;
};
type IngestResult = { rejected: { insert_id: string | null; retryable: boolean }[] };
let events: AnalyticsEvent[] = [];
let sending = false;
let stopped = false;
let retryAt = 0;
let backoffMs = 1000;
// The queue from previous launches is read once; every operation waits for it first.
const loaded = AsyncStorage.getItem(QUEUE_KEY)
.then((saved) => {
events = saved ? JSON.parse(saved) : [];
})
.catch(() => {});
function save(): Promise<void> {
return AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(events));
}
export async function enqueue(event: AnalyticsEvent): Promise<void> {
await loaded;
events.push(event);
await save(); // disk first: the event survives the app being closed
await flush();
}
export async function flush(): Promise<void> {
await loaded;
const minTime = Date.now() - MAX_AGE_MS;
events = events.filter((e) => Date.parse(e.timestamp) >= minTime);
if (sending || stopped || events.length === 0 || Date.now() < retryAt) return;
sending = true;
const batch = events.slice(0, MAX_BATCH);
const sent = new Set(batch.map((e) => e.insert_id));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", "X-App-Key": WRITE_KEY },
// sent_at is this attempt's time: the server uses it to correct a skewed device clock.
body: JSON.stringify({ sent_at: new Date().toISOString(), events: batch }),
signal: controller.signal,
});
if (res.status === 200) {
const result: IngestResult = await res.json();
const retry = new Set(result.rejected.filter((r) => r.retryable).map((r) => r.insert_id));
events = events.filter((e) => !sent.has(e.insert_id) || retry.has(e.insert_id));
backoffMs = 1000;
if (retry.size > 0) retryLater(60_000);
} else if ([400, 413, 415].includes(res.status)) {
events = events.filter((e) => !sent.has(e.insert_id)); // retrying won't help
} else if (res.status === 401 || res.status === 403) {
stopped = true; // key revoked or app archived
} else if (res.status === 429 || res.status === 503) {
retryLater((Number(res.headers.get("Retry-After")) || 5) * 1000);
} else {
backoff();
}
} catch {
backoff(); // network error, timeout, unexpected response
} finally {
clearTimeout(timer);
sending = false;
await save();
}
}
function backoff() {
retryLater(backoffMs * (0.8 + Math.random() * 0.4));
backoffMs = Math.min(backoffMs * 2, 300_000);
}
function retryLater(delayMs: number) {
retryAt = Date.now() + delayMs;
setTimeout(() => void flush(), delayMs);
}Events older than 7 days are dropped before sending: the server would reject them with
event_too_old anyway. An event sent three days late lands in the report for the day it happened
(see Time and the offline queue).
Sessions
analytics.ts creates the events. Each event carries its own context, so queued events go out
with the app version they happened in. Duration is measured with performance.now(), a monotonic
clock that doesn't change when the user changes the system time.
import { Platform } from "react-native";
import DeviceInfo from "react-native-device-info";
import { v4 as uuidv4 } from "uuid";
import { installId } from "./installId";
import { type AnalyticsEvent, enqueue, flush } from "./queue";
let session: { id: string; startedAt: number } | null = null;
let userId: string | null = null;
function context(): Record<string, string> {
const ios = Platform.OS === "ios";
return {
platform: ios ? "ios" : "android",
os_name: DeviceInfo.getSystemName(),
os_version: DeviceInfo.getSystemVersion(),
app_version: DeviceInfo.getVersion(),
app_build: DeviceInfo.getBuildNumber(),
// On iOS, a machine name like iPhone17,3: the device name often contains the owner's name.
device_model: ios
? DeviceInfo.getDeviceId()
: `${DeviceInfo.getBrand()} ${DeviceInfo.getModel()}`,
device_type: DeviceInfo.isTablet() ? "tablet" : "phone",
locale: Intl.DateTimeFormat().resolvedOptions().locale,
environment: __DEV__ ? "development" : "production",
};
}
async function track(name: AnalyticsEvent["name"], sessionId: string, durationMs?: number) {
// Time, user_id and context are captured when the event happens, not once install_id is read.
const event = {
insert_id: uuidv4(),
name,
timestamp: new Date().toISOString(),
session_id: sessionId,
user_id: userId ?? undefined, // undefined is left out of the JSON
duration_ms: durationMs,
context: context(),
};
await enqueue({ ...event, install_id: await installId() });
}
export const analytics = {
start() {
if (session) return;
session = { id: uuidv4(), startedAt: performance.now() };
void track("session_start", session.id);
},
end() {
if (!session) return;
const elapsed = Math.round(performance.now() - session.startedAt);
void track("session_end", session.id, Math.min(Math.max(elapsed, 0), 86_400_000));
session = null;
},
// Account ID from your backend, null after sign-out. Not an email or phone number.
setUserId(id: string | null) {
userId = id;
},
flush,
};Subscribe to AppState in the root component: it lives as long as the app runs. active opens a
session, background closes it. The inactive state exists only on iOS (Notification Center, the
app switcher, an incoming call) — skip it. Calling start() again while a session is open, or
end() with no session, does nothing.
import { useEffect } from "react";
import { AppState } from "react-native";
import { analytics } from "./analytics";
import { RootNavigator } from "./navigation";
export default function App() {
useEffect(() => {
if (AppState.currentState === "active") analytics.start();
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") analytics.start();
else if (state === "background") analytics.end();
});
return () => subscription.remove();
}, []);
return <RootNavigator />;
}On Android, AppState switches to background as early as the activity's onPause, so a system
permission dialog shown over the app closes the session and opens a new one. An app gets little
time in the background: if session_end doesn't go out in time, it stays in the queue and is sent
on the next launch.
user_id after sign-in
After sign-in, pass the account ID from your backend; after sign-out, pass null. Anonymous events
from before sign-in are attributed to that user (see Identity). If the
user signed in during a previous run, call setUserId before the first session_start — for
example, when you restore the signed-in session at launch.
analytics.setUserId(account.id); // "a3f9c2d1-77b4-…", not an emailVerify
- Run a debug build (
npx react-native run-iosorrun-android): with__DEV__, events go out withenvironment: "development". - Send the app to the background and open it again.
session_startandsession_endshow up on the Integration tab with the iOS or Android platform. In reports, turn on the development environment.