@lumin-monitor/react-native
v0.5.1
Published
React Native SDK for Lumin: screen / track / identify with batched ingest.
Maintainers
Readme
@lumin-monitor/react-native
React Native SDK for Lumin. Drop screen / track /
identify into your mobile app; per-session timelines join with your
server-side logs and your web events via session_id.
Mirror of the @lumin-monitor/browser SDK, adapted for the
React Native runtime. Same wire format, same lmn_pub_* API key kind,
same /v1/events endpoint — so a single Lumin project can hold events
from your web app, your mobile app, and (after identify) tie them to
the same user.
Install
pnpm add @lumin-monitor/react-native
# plus, if you want persistent ids across app launches (recommended):
pnpm add @react-native-async-storage/async-storage
# plus, if you want device-model tracking in the sessions UI:
pnpm add react-native-device-info@react-native-async-storage/async-storage is no longer auto-detected
(it broke Metro bundling — see CHANGELOG 0.3.0). Install
it and pass it to init({ storage }) to persist anonymous_id across app
launches and let the 30-minute idle session window survive a cold start.
Omit storage (or pass null) to use in-memory ids that reset on every
cold start — fine for prototypes.
react-native-device-info is also opt-in (same reason). Without it the
SDK still reports os + os_version via the X-Lumin-Client header —
see Device classification.
React Navigation is an optional peer dep, only needed if you import
@lumin-monitor/react-native/react-navigation.
Quick start
import AsyncStorage from "@react-native-async-storage/async-storage";
import { init } from "@lumin-monitor/react-native";
export const lumin = init({
apiKey: process.env.EXPO_PUBLIC_LUMIN_BROWSER_API_KEY!,
storage: AsyncStorage,
});
// Bound methods, safe to destructure:
export const { screen, track, identify, flush, close } = lumin;apiKey is a lmn_pub_* key minted in Settings → API keys with kind
Browser SDK (the same key kind covers all client-side SDKs). The
server enforces "pub keys can only post to /v1/events" — even if an
attacker extracts the key from a decompiled IPA / APK they cannot post
logs or metrics, query any data, or pivot to a different endpoint.
Rotate from Settings → API keys if it leaks.
If you accidentally paste a lmn_priv_* (server / agent kind) key here
the server will return 403 with a hint pointing you back at the mint flow
— go pick Browser SDK instead.
endpoint defaults to https://api.getlumin.dev. The Lumin API allows
cross-origin requests, so your bundle identifier does not need to match
any particular host.
API
init(options): LuminClient
init is synchronous: it returns a client immediately and hydrates
the persistent ids from AsyncStorage in the background. You can call
screen / track / identify right away; the first flush waits for
hydration to complete before sending.
| Option | Default | Notes |
| ----------------- | ----------------------------- | --------------------------------------------------------------------------- |
| apiKey | — | Required. lmn_pub_… from Settings → API keys (kind: Browser SDK). |
| endpoint | https://api.getlumin.dev | Override only for local dev or same-origin proxy. See below. |
| batchSize | 50 | Max events buffered before a forced flush. |
| flushIntervalMs | 500 | Max ms between flushes. |
| sessionIdleMs | 1800000 (30 min) | A new session id is minted on the next event after this much inactivity. |
| onError | console.warn | Called as (err, droppedCount) when a batch fails. |
| fetch | global fetch | Override for tests. |
| storage | null (in-memory ids) | Pass AsyncStorage for persistence across launches. See Install above. |
| appState | auto-detect react-native | Pass null to disable the background-flush listener. |
| captureUnhandledErrors | true | Install an ErrorUtils.setGlobalHandler chain. See Error capture below. |
| errorUtils | auto-detect global ErrorUtils | Override the ErrorUtils-like object the SDK installs into. |
| deviceInfo | null (no device model) | Pass DeviceInfo from react-native-device-info to include the device model in the X-Lumin-Client header. See Device classification below. |
screen(name?, properties?)
Fire on navigation. Most apps use the React Navigation helper below; you
can also call screen() manually from a screen component's useEffect.
screen(); // current screen, no name
screen("Settings"); // named view
screen("Settings", { tab: "billing" }); // with propertiesOn the wire screen emits type: "page", matching the browser SDK and
the existing server-side schema. The mobile-idiom method name is purely
cosmetic.
track(name, properties?)
Custom events. Names are free-form; the Sessions UI lets you filter by
name, so keep them stable (signup_completed good, not
signup_completed_v2_2026_05).
track("signup_completed", { plan: "indie", source: "appstore" });
track("checkout_clicked");identify(userId, traits?)
Bind the current anonymous session to a known user — typically called
right after login. Every prior event in the session is retroactively
associated with userId when the session timeline is rendered.
identify("user_abc123");
identify("user_abc123", { plan: "indie", signedUpAt: "2026-05-01" });Re-call on every app launch while the user is signed in. It is cheap and ensures a cold start still binds the session.
captureError(err, properties?)
Capture an error. The auto-installed global handler catches uncaught JS
throws (and rejected promises that bubble up to RN); call this directly
from try/catch blocks where you'd otherwise swallow the failure.
try {
await applyDiscount(code);
} catch (err) {
captureError(err, { code, step: "checkout" });
showToast("Discount didn't apply");
}Accepts a real Error (preferred — preserves stack and constructor name
as error_type), or any value that will be stringified for the message.
null / undefined are silently ignored. The same Error object
captured twice in the same tick is deduped.
Auto error capture
When captureUnhandledErrors is true (the default), the SDK installs a
handler via ErrorUtils.setGlobalHandler that chains to the previous
handler, so RN's red-box, the LogBox, and any other error tool already
installed still fire. The SDK does not swallow the throw.
Fatal errors (isFatal: true) trigger an immediate flush() so the
error row has a chance to leave the device before the RN runtime tears
down. Best-effort: the OS may suspend the JS thread before the request
lands, but the standard ~30 s background grace window covers the
common case.
What is NOT captured:
- Native iOS / Android crashes. RN's
ErrorUtilsis JS-only. Native crashes need a native module (Crashlytics, Sentry-native, Bugsnag's native bridge) — out of scope for this SDK. - Source-mapped stacks. Stacks ship as the minified RN bundle frames ship them. Symbolication against the bundle's source maps is a v2 concern.
Opt out with captureUnhandledErrors: false if another tool already
owns the global handler; manual captureError(err) still works.
flush(): Promise<void>
Force a flush of any buffered events. The SDK auto-flushes on AppState
background / inactive; call this manually only when you need to
guarantee delivery before kicking off something that may suspend the
runtime (a deep link out to a payment SDK, an OAuth handoff, etc.).
close(): Promise<void>
Flush, then tear the SDK down. Subsequent screen/track/identify
calls become no-ops. Use only when you genuinely want to stop emitting
events for the rest of the app's lifetime — uncommon.
getSessionId() / getAnonymousId(): Promise<string>
Resolve the current ids. Both await AsyncStorage hydration. Use them to
attach a X-Lumin-Session header to your API calls so server-side logs
join with the session timeline (see "Linking events to server logs"
below).
React Navigation integration
import { NavigationContainer, createNavigationContainerRef } from "@react-navigation/native";
import { useLuminScreenviews } from "@lumin-monitor/react-native/react-navigation";
const navigationRef = createNavigationContainerRef();
export default function App() {
useLuminScreenviews(lumin, navigationRef);
return (
<NavigationContainer ref={navigationRef}>
<RootStack />
</NavigationContainer>
);
}Fires screen() once per route name change. Deliberately ignores param
changes — those are usually filter state, not real screen views, and
double-counting them inflates funnel metrics. If you want per-param
tracking, call screen() manually from the route's effect.
Peer deps: react >= 18, @react-navigation/native >= 6. Both are
optional — the subpath only loads them when imported.
Device classification
Browser SDKs send a meaningful User-Agent; the Lumin server parses it
into ua_browser / ua_os / ua_device_type columns that the sessions
UI groups by. React Native's fetch sends okhttp/… or CFNetwork/…,
which carries no device info, so the SDK ships an X-Lumin-Client header
on every batch instead:
X-Lumin-Client: sdk=rn/0.4.0; os=ios; os_version=17.4os and os_version come from React Native's built-in Platform
constants — no peer dep required. The server folds the header into the
same ua_os / ua_device_type columns the browser SDK has always used,
so mobile sessions show up in the UI alongside web sessions.
For the device model (iPhone15,3, Pixel 7), install
react-native-device-info
and pass it through:
import DeviceInfo from "react-native-device-info";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { init } from "@lumin-monitor/react-native";
export const lumin = init({
apiKey: process.env.EXPO_PUBLIC_LUMIN_BROWSER_API_KEY!,
storage: AsyncStorage,
deviceInfo: DeviceInfo, // populates X-Lumin-Client: device=<model>
});The SDK never require()s react-native-device-info — same opt-in
policy as storage, to keep Metro bundling clean (see CHANGELOG 0.3.0).
If the installed version of react-native-device-info ever changes its
shape, ship a tiny adapter that satisfies DeviceInfoLike
({ getModel(): string }).
Session semantics
Sessions are idle-based, not tied to a single foreground period:
- A
session_idis minted on the first event after install / clear. - It persists across app launches in AsyncStorage.
- It is replaced on the next event if more than
sessionIdleMs(default 30 minutes) has elapsed since the last event. - AppState background / foreground does not by itself rotate the session — closing the app for 5 minutes and reopening keeps the same session, which matches what every other mobile analytics tool does.
This matches the convention used by GA, Mixpanel, and friends, and lines
up with the browser SDK's sessionStorage-scoped tab session: both
represent "the user's current burst of activity".
Linking events to server logs
The whole point of the SDK is the join with your server-side logs. To
make "tap a 500 log row → jump to the session" work, your server has
to know the mobile client's session_id.
The pattern: attach X-Lumin-Session to every API call, and have your
server-side logger include that value as session_id on every log row
it emits during that request. Lumin joins on the field automatically.
const sessionId = await lumin.getSessionId();
fetch("https://api.example.com/orders", {
headers: { "X-Lumin-Session": sessionId, ... },
});This SDK exposes the ids but does not inject the header for you — pick where in your networking layer to set it.
Delivery semantics
- Batched. Up to
batchSizeevents orflushIntervalMs, whichever fires first. - Auto-flushes on
AppStatetransition tobackgroundorinactive. RN keeps the JS runtime alive for ~30 s after backgrounding, which is enough for one batched POST to land. There is nokeepalive: trueequivalent on RNfetch— the OS-level grace window is the contract. - Drops, never throws, on network failure. The
onErrorcallback is the only signal — wire it to your own observability if you care about drop rates. - No retries today. A failed batch is gone.
- No on-disk spool. If the app is force-killed before a flush completes, the buffered batch is lost (≤500 ms worth at default settings).
These trade-offs match the SDK's purpose: capture user behavior, not deliver every event under adverse network conditions. If you need guaranteed delivery, write it as a server-side log instead.
Security
The API key is write-only and kind-restricted. Browser SDK keys
(lmn_pub_*) can post to /v1/events for one specific project. They
cannot read events, cannot touch any other project, cannot post logs or
metrics, and cannot reach the app UI. Treat the key like a public
token — anyone who decompiles your app can extract it. The real control
is rotation, not concealment.
The SDK only sends what you pass it. It does not introspect view
hierarchies, scrape form fields, or capture network traffic. The data
sent to Lumin is exactly what you put in properties plus session_id,
anonymous_id, and (after identify) user_id. Don't put secrets in
properties.
Endpoint override. The endpoint option exists for local dev and
same-origin proxies. The SDK validates the shape (requires https://
for non-local hosts, rejects paths/queries/fragments, rejects
non-http(s) schemes) and throws synchronously on a bad value. This
catches typos and accidental misconfiguration, not a determined
attacker — code that controls the SDK config already has stronger
primitives (direct fetch, etc.).
TypeScript
Ships its own .d.ts files for both ESM and CJS. InitOptions,
LuminClient, AsyncStorageLike, AppStateLike, EventType, and
WireEvent are exported for code that needs to reference them directly.
License
Apache-2.0. See LICENSE.
