# React Native SDK (@lumin-monitor/react-native)

> screen / track / identify / error capture for mobile apps, sharing one project with your web events.

Canonical: https://docs.getlumin.dev/stacks/react-native/

`@lumin-monitor/react-native` is the mobile half of the Lumin instrumentation story. Everything server-side — logs, metrics, container observation — flows through the agent. An SDK is unavoidable inside a user's app, because there is no agent there.

It mirrors [`@lumin-monitor/browser`](https://docs.getlumin.dev/stacks/browser/): same wire format, same `lmn_pub_*` key kind, same `/v1/events` endpoint. One Lumin project can therefore hold events from your web app and your mobile app, tied to the same person once you call `identify`.

The SDK does four things: `screen` for view tracking, `track` for custom events, `identify` for binding the session to a known user, and automatic capture of unhandled JS errors.

## Install

```sh
pnpm add @lumin-monitor/react-native
```

Two optional peers, both worth adding:

```sh
# persistent ids across app launches (recommended)
pnpm add @react-native-async-storage/async-storage
# device model in the sessions UI
pnpm add react-native-device-info
```

Neither is auto-detected. The SDK never `require()`s them, which keeps Metro bundling clean — you pass them to `init()` yourself.

## Initialise once at app boot

```ts
import { init } from "@lumin-monitor/react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";

export const lumin = init({
  apiKey: process.env.EXPO_PUBLIC_LUMIN_KEY!,
  storage: AsyncStorage,
});

export const { screen, track, identify, captureError, flush } = lumin;
```

`init` is synchronous. The client buffers events while it reads storage for the persistent ids in the background, so you can call `screen()` and `track()` immediately — the first flush waits for hydration.

Calling `init` more than once produces independent clients. Pin one at module level if you want a single shared instance.

The `apiKey` is a `lmn_pub_*` key minted in **Settings → API keys** with **Kind = Browser SDK**. It ships inside your app bundle and is extractable — that is expected. The server enforces that pub keys can only post `/v1/events`, so a leaked key cannot read data, post logs or metrics, or reach another project. A `lmn_priv_*` key sent here returns 403.

### Options

| Option | Default | Notes |
|---|---|---|
| `apiKey` | — | Required. `lmn_pub_…` from Settings → API keys. |
| `endpoint` | `https://api.getlumin.dev` | Must be a valid URL with no path beyond `/`. `https://` is required except for `localhost`, `127.0.0.1`, and `.localhost` subdomains. Invalid shapes throw from `init()`, so a misconfiguration surfaces at boot. |
| `storage` | `null` | Pass `AsyncStorage`. Omit for in-memory ids that reset on every cold start. |
| `batchSize` | `50` | Max events buffered before a forced flush. |
| `flushIntervalMs` | `500` | Max ms between flushes. |
| `sessionIdleMs` | 30 min | A new `session_id` is minted on the next event after this much idle time. |
| `captureUnhandledErrors` | `true` | See below. |
| `deviceInfo` | `null` | Pass `DeviceInfo` to add the device model to the sessions UI. |
| `visitorPing` | `false` | Fire one anonymous visitor heartbeat at init. |
| `onError` | `console.warn` | Called with the failed batch and dropped count. |

## Screen views

```ts
screen("Pricing");
screen("Pricing", { plan: "solo" });
```

### React Navigation

Import the hook from the `/react-navigation` subpath and mount it once, beside your `NavigationContainer`:

```ts
import { useLuminScreenviews } from "@lumin-monitor/react-native/react-navigation";

useLuminScreenviews(lumin, navigationRef);
```

It fires `screen()` on every route change, and once on mount if navigation has already hydrated. **Repeat navigations to the same route name do not re-emit** — params are usually filter state rather than a real screen view, and double-counting them inflates funnel numbers. Call `screen()` yourself from the route's effect if you want per-param tracking.

Pass `null` for the client and the hook becomes a no-op, which is the clean way to disable the SDK in development.

## Custom events

```ts
track("checkout_started", { plan: "solo" });
```

Keep names stable. The Sessions page filters by name, so `signup_completed` is good and `signup_completed_v2` is not.

## Identify a user

```ts
identify("user_abc123", { plan: "solo" });
```

Call it after login. The SDK already holds an anonymous session; `identify` binds it to your user id, and the whole session reads back against that user. Because the browser SDK writes into the same project with the same call, a person who uses your web app and your mobile app resolves to one user.

## Unhandled errors

Auto-capture is **on by default**. The SDK installs a global handler through `ErrorUtils.setGlobalHandler` and chains to any handler already installed, so React Native's red box and any other reporter still fire.

```ts
init({ apiKey, captureUnhandledErrors: false }); // e.g. Sentry owns the handler
```

You can also report a caught error yourself:

```ts
captureError(err, { screen: "Checkout" });
```

Pass a real `Error` where you can — the SDK keeps the stack and uses the constructor name as `error_type`. Any other value is stringified into the message.

Errors land in the session timeline in order with the screens before them, so you see what the user did on the way to the failure. There is no issue list, no grouping, and no dedupe; this is not an error tracker.

**Native crashes are not captured.** `ErrorUtils` sees the JS thread only. An iOS or Android crash needs native modules that are outside this SDK's scope.

## Persistent ids

Without `storage`, the `anonymous_id` and `session_id` live in memory and reset on every cold start. Every launch then looks like a new person. Pass `AsyncStorage` unless you are prototyping.

## Flushing

The SDK flushes when the app goes to the background, via `AppState`. Force one before something you expect to lose:

```ts
await flush();
```

## Visitor counts

`visitorPing: true` POSTs one empty request to `/v1/visit` at init, so the project's visitor gauge counts the launch before any screen or track call. It carries no payload, no identifiers, and no cookies — the server derives a daily-rotating, non-personal hash from the connection and the `X-Lumin-Client` header. It is safe to enable without consent. `screen`, `track`, and `identify` stay behind whatever consent layer you wrap the SDK in.
