# React Native SDK

@cohorly/react-native is a standalone React Native client. It has no hard dependency on react-native or any storage library - it works out of the box with an in-memory store and upgrades to persistent storage when you inject one.

## Install

```bash
pnpm add @cohorly/react-native
# recommended, for persistence across app restarts
pnpm add @react-native-async-storage/async-storage
```

## Initialize

Call `init()` once near app launch. Inject AsyncStorage so the distinct id,
queued events, and super properties survive restarts.

```ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import { init, track, identify, people } from "@cohorly/react-native";

init({
  token: "YOUR_PROJECT_TOKEN",
  storage: AsyncStorage, // omit to fall back to in-memory storage
});

track("Screen Viewed", { screen: "Home" });
identify("user_123");
people.set({ plan: "pro" });
```

Without `storage`, the client uses `InMemoryStorage` (also exported),
which works but loses the distinct id, queue, and super properties on
every app restart.

## API

| Member                                                     | Signature                                        | Notes                                                                                                                                                                                                                                                                                                                                               |
| ---------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `init`                                                     | `(options) => CohorlyClient`                     | All optional: `token`, `apiHost` (defaults to the hosted API), `storage`, `flushInterval`, `flushAt`, `fetch`, `disabled`, `loadFeatureFlags`, `sendExposureEvents`, `flagPersistence`, `flagCacheTtlMs`, and the [error tracking](#error-tracking) options `captureExceptions`, `release`, `dist`, `beforeSendException`, `maxExceptionQueueSize`. |
| `track`                                                    | `(event, properties?) => void`                   | Queue an event.                                                                                                                                                                                                                                                                                                                                     |
| `identify`                                                 | `(distinctId) => void`                           | Switches identity synchronously, then posts `/alias` (best effort) when the previous id was anonymous.                                                                                                                                                                                                                                              |
| `reset`                                                    | `() => void`                                     | Clears identity, super properties, and un-flushed events.                                                                                                                                                                                                                                                                                           |
| `register` / `unregister`                                  | `(props)` / `(key)`                              | Super properties merged into every event.                                                                                                                                                                                                                                                                                                           |
| `people.*`                                                 | `set / setOnce / increment / unset / deleteUser` | Queued and sent to /engage.                                                                                                                                                                                                                                                                                                                         |
| `flush`                                                    | `() => Promise<void>`                            | Manual flush.                                                                                                                                                                                                                                                                                                                                       |
| `getDistinctId`                                            | `() => string`                                   | Current distinct id.                                                                                                                                                                                                                                                                                                                                |
| `getFeatureFlag` / `isFeatureEnabled`                      | `(key, fallback?)`                               | Flag reads; optional fallback for unknown/unloaded flags (never an exposure).                                                                                                                                                                                                                                                                       |
| `getFeatureFlagDetails`                                    | `(key, fallback?) => FlagDetails`                | Full result with `source` (`"network"` / `"persistence"` / `"fallback"`) plus `ttlMs` - and `persistedAt` when the cache carries `stored_at` - when served from the cache.                                                                                                                                                                          |
| `getFeatureFlagPayload`                                    | `(key) => unknown \| null`                       | Variant payload.                                                                                                                                                                                                                                                                                                                                    |
| `reloadFeatureFlags` / `onFeatureFlags`                    | `() => Promise<void>` / `(cb) => unsub`          | Re-evaluate; subscribe to updates.                                                                                                                                                                                                                                                                                                                  |
| `captureException`                                         | `(error, options?) => void`                      | Queue a `$exception` event. Never throws, no-op before `init()`.                                                                                                                                                                                                                                                                                    |
| `addBreadcrumb`                                            | `(crumb) => void`                                | Record a step sent with the next exceptions.                                                                                                                                                                                                                                                                                                        |
| `trackNavigation`                                          | `(navigationRef) => unsubscribe`                 | Navigation breadcrumbs from a React Navigation or expo-router ref.                                                                                                                                                                                                                                                                                  |
| `CohorlyErrorBoundary`                                     | component                                        | Catches render errors, captures them, renders `fallback`.                                                                                                                                                                                                                                                                                           |
| `optOutTracking` / `optInTracking` / `hasOptedOutTracking` | `() => void` / `() => void` / `() => boolean`    | Device opt-out, persisted. Opting out drops everything queued and clears breadcrumbs; while opted out nothing is sent (events, people, alias, `$exception`) and no flag request is made. No-ops (and `false`) before `init()`.                                                                                                                      |
| `isExceptionCaptureEnabled` / `onExceptionCaptureChange`   | `() => boolean` / `(listener) => unsub`          | Whether an exception would be recorded now: `false` until the stored state is read, when opted out, or with `captureExceptions: false`. The listener fires when the value changes; native crash reporters take `enabled` from it.                                                                                                                   |

Flags are persisted per identity (`cohorly:flags`, scoped by `distinct_id`) and
bounded by `flagCacheTtlMs` (default 86400000 ms, 24 h). `flagPersistence`
selects the policy - `"persistenceUntilNetworkSuccess"` (default, still
serves an expired cache up to 7 days stale), `"networkFirst"` (strictly
within the TTL), or `"networkOnly"` (never touches the cache). A legacy
cache without `stored_at` is served indefinitely until a successful reload
rewrites it. See
[Feature flags](/feature-flags#flag-persistence).

Note the People delete method here is `people.deleteUser()` (not `delete`).
`identify()` applies the identity switch synchronously and then fires an
`/alias` post in the background: it is never awaited, and a failure is
swallowed rather than retried. That is safe because every event already
carries `$device_id` and, once identified, `$user_id`, so the server links
the anonymous and identified ids implicitly from the next `track()` call
even if the `/alias` post never lands. See
[Identity](/reference/data-model#identity). Call `reset()` on logout so a
shared device doesn't carry one person's identity into the next session.

## Batching

Events are queued and flushed automatically every 5 seconds or once 20
events have accumulated - both configurable via `flushInterval` and
`flushAt`. Queued events are persisted to storage, so nothing is lost if
the app closes before a flush. You can also construct
`new CohorlyClient(options)` directly for multiple instances or dependency
injection.

## Custom metrics

`metrics.count`, `gauge` and `distribution` have the same signature and send
the same `$metric` events as `@cohorly/core` (see
[Core SDK](/sdks/core#custom-metrics)): one event per metric name and tag
combination per 60 second window, never one per sample.

```ts
import { init, metrics } from "@cohorly/react-native";

const client = init({ token: "<project token>", storage: AsyncStorage });

metrics.count("checkout_started", 1, { plan: "pro" });
metrics.gauge("queue_length", queue.length);
metrics.distribution("js_cost", elapsedMs, { tags: { service: "cart" }, unit: "ms" });

// Same thing on the client:
client.metrics.distribution("js_cost", elapsedMs, { service: "cart" });
```

The module helpers do nothing before `init()` and never throw. The `metrics`
option takes `flagKey`, `windowMs` and `maxSeries`.

- **Kill switch.** Deactivate the feature flag `cohorly_metrics` (or set its
  rollout to 0%) and the client stops recording and drops the window in
  progress. A missing flag means on.
- **Background.** The window in progress is queued when the app goes to the
  background or becomes inactive, so a killed app loses at most the samples of
  the last foreground stretch. `identify()` also queues it first, so the
  samples keep the identity they were taken under; `reset()` and
  `optOutTracking()` discard it.
- **Flags not loaded yet.** While the first flag evaluation is running and no
  flags are loaded, `$metric` events stay in the queue and are not sent. A
  disabled `cohorly_metrics` then drops them; otherwise they are sent. An
  evaluation that fails, or `loadFeatureFlags: false`, holds nothing back.

## Error tracking

Errors are ordinary events named `$exception`, so they can be broken down,
filtered and put into funnels like any other event. Capture one yourself:

```ts
import { captureException } from "@cohorly/react-native";

try {
  await submitOrder(cart);
} catch (error) {
  captureException(error, {
    level: "error", // "fatal" | "error" (default) | "warning" | "info"
    title: "Checkout submit failed", // display title, also prefixes the fingerprint
    tags: { step: "payment" },
    extra: { cartSize: cart.items.length },
  });
}
```

The event carries the error and its `cause` chain with the Hermes (or
JavaScriptCore) stack parsed into frames, the current breadcrumbs, the
release, and the same default and super properties as `track()` (`$os`,
`$model`, `$app_version_string`, `$app_build_number`, `$release`,
`$release_dist`, ...). Every event, not only exceptions, carries `$release`
and `$release_dist` (the same values as `$exception_release` /
`$exception_dist`; no dist on the embedded bundle), which is what Errors >
Releases groups crash-free users by. `tags` and `extra`
never overwrite a reserved key (`$exception_*`, `error_title`, `distinct_id`,
`$device_id`, `$user_id`, ...). A thrown value that is not an `Error` is sent
as a synthetic exception. The identity is the one in effect when the error is
captured: an `identify()` afterwards does not change it.

### Automatic capture

```ts
init({
  token: "YOUR_PROJECT_TOKEN",
  storage: AsyncStorage,
  captureExceptions: true,
});
```

With `captureExceptions: true` the SDK installs:

- **A global JS error handler** (`ErrorUtils.setGlobalHandler`), chained to the
  one already there. A fatal error is sent with `level: "fatal"` (it counts
  against crash-free users), a non-fatal one with `level: "error"`, both with
  `mechanism: { type: "onerror", handled: false }`. The fatal exception is
  written to AsyncStorage before the previous handler runs (which usually
  closes the app); the SDK waits for that write at most 1.5 seconds. It is
  sent right away when the network allows, and otherwise on the next launch,
  still with the release it crashed in.
- **An unhandled promise rejection tracker** on Hermes
  (`HermesInternal.enablePromiseRejectionTracker`), sent with
  `level: "error"` and `mechanism.type: "onunhandledrejection"`. In `__DEV__`
  the usual "Possible Unhandled Promise Rejection" warning is kept. Without
  Hermes nothing is installed. A rejection with a value that is not an
  `Error` is reported as `UnhandledRejection`, as on the web.
- **Automatic breadcrumbs**:

| Category        | What                                                                                       | Never                                                           |
| --------------- | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------- |
| `app.lifecycle` | The new `AppState` (`"background"`, `"active"`)                                            |                                                                 |
| `fetch`         | `"POST https://api.example.com/v1/cart 500"` for failed (status 400+) or rejected requests | query string, fragment, headers, bodies, the SDK's own requests |

Without `captureExceptions` none of this is touched:
`ErrorUtils`, the rejection tracker, `fetch` and `AppState` stay as they are,
and `captureException()` still works. `captureExceptions: false` goes further:
nothing global is installed and no `$exception` is sent at all, manual
`captureException()` included - the switch for a user who turned error reports
off. `optOutTracking()` stops exceptions along with everything else. `stop()` on the client removes the
handlers and ends its exception capture, and calling `init()` again stops the
previous client the same way, so two clients never share the persisted
exception queue. Use one `init()` per app.

The same error is sent at most 10 times per minute (same type, message and
crashing frame), whether captured automatically or with `captureException()`;
a render loop cannot flood the queue.

### Navigation breadcrumbs

Pass your navigation container ref to `trackNavigation()`. It records
`navigation` breadcrumbs such as `"Home -> Cart"` with route names only -
params are never read, so ids and e-mails in them never leave the device.

```tsx
import { NavigationContainer, useNavigationContainerRef } from "@react-navigation/native";
import { trackNavigation } from "@cohorly/react-native";

export function App() {
  const navigationRef = useNavigationContainerRef();
  useEffect(() => trackNavigation(navigationRef), [navigationRef]);
  return <NavigationContainer ref={navigationRef}>{/* ... */}</NavigationContainer>;
}
```

With expo-router, use `useNavigationContainerRef()` from `expo-router` in the
root layout the same way.

### Breadcrumbs and scrubbing

Add your own breadcrumbs with `addBreadcrumb()`; the last 50 travel with every
exception. They are kept in memory only, and `reset()` clears them (queued
exceptions are kept and still sent).

```ts
addBreadcrumb({ category: "checkout", message: "Applied coupon", level: "info" });
```

`beforeSendException` sees every exception right before it is queued. Return
the event, changed if needed, or `null` to drop it. If the hook throws, the
exception is dropped - it is never sent unscrubbed.

```ts
import { init, type ExceptionListEntry } from "@cohorly/react-native";

init({
  token: "YOUR_PROJECT_TOKEN",
  captureExceptions: true,
  beforeSendException(event) {
    const exceptions = event.properties.$exception_list as ExceptionListEntry[];
    for (const exception of exceptions) {
      exception.value = exception.value.replace(/[\w.+-]+@[\w-]+\.[\w.]+/g, "[email]");
    }
    return event;
  },
});
```

### Error boundary

`CohorlyErrorBoundary` catches render errors below it, captures them
(`mechanism.type: "react.errorboundary"`, level `"error"`, the component stack
as a `react.component_stack` breadcrumb) and renders `fallback`. It does not
need `@cohorly/react`; without `init()` it still renders the fallback.

```tsx
import { CohorlyErrorBoundary } from "@cohorly/react-native";

<CohorlyErrorBoundary
  fallback={({ reset }) => <Button title="Try again" onPress={reset} />}
  onError={(error, componentStack) => console.log(componentStack)}
>
  <CartScreen />
</CohorlyErrorBoundary>
```

### Delivery

Exceptions have their own persisted queue (`maxExceptionQueueSize`, 50 by
default), so offline events can never push one out. Every flush sends
exceptions first, fatal ones before the rest, then events; when the
exceptions cannot be delivered the events wait for the next flush, unless the
exception request stalls for more than 10 seconds. With
`disabled: true` nothing is sent and exceptions stay persisted.

### Release, dist and OTA updates

Cohorly finds the source map for a crash by the exception's release and dist,
so they must equal what you upload with
[`cohorly sourcemaps upload`](/cli):

- **`release`** defaults to `<bundleId>@<version>+<build>`, e.g.
  `com.example.shop@3.8.1+415`. The bundle id comes from `expo-application`
  (`applicationId`) or `react-native-device-info` (`getBundleId()`); without
  either, the lowercase platform is the prefix (`android@3.8.1+415`). With no
  build number there is no `+` part; with no app version there is no release.
  A `release` passed to `init()` is used exactly as given.
- **`dist`** defaults to the running `expo-updates` update id when the bundle
  is a downloaded OTA update, and is absent for the bundle embedded in the
  binary. A `dist` passed to `init()` wins. Every exception also carries
  `expo_update_id`, `expo_channel`, `expo_runtime_version` and
  `expo_is_embedded_launch` (only the ones `expo-updates` reports).

```bash
# binary build: the embedded bundle has no dist
cohorly sourcemaps upload --release com.example.shop@3.8.1+415 build

# OTA update: same release, the update id as dist
cohorly sourcemaps upload --release com.example.shop@3.8.1+415 --dist "$UPDATE_ID" dist
```

Frames from the app's bundle are reported under the canonical bundle name -
`index.android.bundle` on Android, `main.jsbundle` on iOS - whatever the file
is called on the device (an OTA bundle has a hashed name), with the device
path kept in `abs_path`. Upload the map under that name (`--bundle-file` for
a Hermes map pair). In development, frames keep the Metro URL.

One OTA update is served to every binary build with the same
`runtimeVersion`, and each of those builds has its own release. Either run
the upload once per release of every build the update targets, or set
`release` yourself to a value shared by the whole runtime (for example
`com.example.shop@runtime-${Updates.runtimeVersion}`) and upload under that.

### Native crashes

A native crash (Swift/Objective-C or Java/Kotlin code that never reaches the JS
handler) is recorded on disk by native code and sent by native code on the next
launch, before JS starts, as an `$exception` event with level `fatal`. On iOS
the reporter is KSCrash (signals, Mach exceptions, NSExceptions, C++
exceptions) plus MetricKit crash, hang and CPU diagnostics; on Android it is
the [Android SDK](/sdks/android#crashes)'s crash reporter, with an optional ANR
watchdog. Add the config plugin and turn it on in `init()`:

```json
"plugins": [["@cohorly/react-native", { "projectToken": "YOUR_PROJECT_TOKEN", "apiHost": "https://YOUR_COHORLY_API" }]]
```

```ts
init({ token: "YOUR_PROJECT_TOKEN", storage: AsyncStorage, nativeCrashReporting: true });
```

- The plugin writes the project token and API host where the native startup
  code reads them (Info.plist on iOS, `<application>` meta-data on Android), so
  a crash before the JS bundle loads is still sent. Both props are required.
  On iOS it also calls `CohorlyNativeCrashes.start()` first thing in the
  AppDelegate's `application(_:didFinishLaunchingWithOptions:)` and forwards
  `application(_:handleEventsForBackgroundURLSession:completionHandler:)` to
  `CohorlyNativeCrashes.handleEventsForBackgroundURLSession(_:completionHandler:)`.
  `androidAnrWatchdog: true` turns on the Android ANR watchdog. Without Expo,
  make the same changes by hand, AppDelegate calls included. Native modules need a rebuild, not an OTA
  update.
- The switch applies to the **next** launch: JS writes `nativeCrashReporting`,
  the release, dist and identity on every launch and on `identify()` /
  `reset()`, and native code reads them before JS exists. It is on only while
  `isExceptionCaptureEnabled()` is true, so `captureExceptions: false` or
  `optOutTracking()` turn it off. Default `false`.
- Never run two native crash handlers: in apps that also use Sentry, set
  `enableNativeCrashHandling: false` in `Sentry.init` first.
- Upload dSYMs (iOS) and R8 mappings (Android) for every build, with the same
  release the SDK sends, so native frames are symbolicated.

Not covered yet: JS errors thrown before `init()` runs.
