Documentation menu

React Native and Expo

@saaspro/react-native adds consent, persisted anonymous and signed-in identity, screen hooks, flags and app lifecycle handling to the existing native transport. Settings → Data → Mobile collection generates scoped Expo or React Native instructions. Use the SDK build containing this package; package publication is a separate release step.

Expo setup

npm install @saaspro/react-native
npx expo install expo-crypto @react-native-async-storage/async-storage
// analytics.ts — create once, outside component rendering
import * as Crypto from "expo-crypto";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { AppState, Platform } from "react-native";
import { SaaSProMobile } from "@saaspro/react-native";

export const analytics = new SaaSProMobile({
  key: "spm_pub_prod_xxxxxxxxxxxxxxxxxxxxxx",
  host: "https://saaspro.dev",
  platform: Platform.OS,
  storage: AsyncStorage,
  randomUUID: Crypto.randomUUID,
  appState: AppState,
  consent: "pending",
  privacyMode: "persistent",
  appVersion: "1.0.0",
  onError: error => console.warn("Analytics unavailable", error),
});

Use only a public ingest key. Application/environment binding comes from that key. The default storage namespace includes the key; rotation starts a fresh identity. Custom storageKey values must be unique per application/environment and use letters, numbers, dots, underscores or hyphens. Stored identities also bind the full key and ingest host, so incompatible state is rejected.

import { SaaSProMobileProvider, useScreen } from "@saaspro/react-native";
import { usePathname } from "expo-router";
import { analytics } from "./analytics";

function AnalyticsScreen() {
  useScreen(usePathname());
  return null;
}

export function Providers({ children }: { children: React.ReactNode }) {
  return <SaaSProMobileProvider client={analytics}>
    <AnalyticsScreen />
    {children}
  </SaaSProMobileProvider>;
}

Expo Router exposes route changes through usePathname. Use static names instead when routes contain user IDs; the SDK removes query/fragment components but cannot infer which path segments are personal. These hooks do not capture search parameters. Expo screen tracking.

For plain React Native, supply a secure generator through react-native-get-random-values and uuid: import the polyfill first, then import { v4 as randomUUID } from "uuid". Rebuild native dependencies after installation. Call useScreen(isFocused ? "Checkout" : null) with your navigator's focus state, or call analytics.screen(routeName) from its ready/state-change callbacks. Tracking every mounted screen can count hidden screens. UUID's native setup, random-values adapter.

await analytics.ready();
await analytics.setConsent("granted"); // Wire to your existing consent controls.
await analytics.identify(user.id, { plan: user.plan });
await analytics.track("activation completed");
await analytics.reset(); // Sign-out: discard old queues, flags and identity.
await analytics.optOut(); // Revoke collection and persist the opt-out choice.

Use the same opaque user.id with browser spm.identify(user.id) and server instrumentation to connect signed-in activity within one application. Signing in from anonymous state joins that visit. Switching between two known users rotates anonymous/session identity and drops the previous account's unsent events. Calling reset() before sign-out completes makes the boundary explicit. Shared user IDs do not prove a payment or authorize access. Anonymous web-to-app install attribution is not inferred from device fingerprints or browser journey tickets.

Default consent is pending. Pending startup reads only the opt-out preference, not tracking identity. Track/error/metric calls made while pending are dropped; only the latest declared screen can be captured after grant. A saved opt-out wins over an initial consent: "granted"; an explicit setConsent("granted") clears it. Revocation immediately aborts transport, clears flags and identity, and schedules serialized storage removal. A late storage write cannot overtake that removal. Already delivered events cannot be retracted by the SDK.

Persistent identity survives restarts through the supplied asynchronous storage adapter. Session idle expiry is configurable from 1–30 minutes (default thirty); clock rollback starts a new session. privacyMode: "ephemeral" removes old tracking storage, keeps identity/flags in memory, ignores identify calls, and rotates anonymous identity on restart, idle expiry, rollback or thirty-minute age. Custom properties/error content are not automatically anonymized. Opt-out preferences remain stored in either mode.

Storage failures call onError. An unreadable opt-out preference leaves collection pending until an explicit choice. Identity persistence failure permits in-memory collection after consent; useMobileStatus().persistence reports the result. Storage waits default to two seconds (100–10,000 ms configurable), while underlying operations remain serialized to preserve revocation ordering. If an OS storage operation never resolves or the process is killed, durable cleanup cannot be confirmed. AsyncStorage is not encrypted; supply a compatible getItem/setItem/removeItem adapter for encrypted storage when needed. Expo SecureStore can be adapted using getItemAsync, setItemAsync and deleteItemAsync. Expo SecureStore.

Screens, errors, flags and lifecycle

API Behavior
useTrack() / track(name, properties?, options?) Current identity; optional value/currency
useIdentify() / identify(id, traits?) Shared signed-in identity in persistent mode
useScreen(name, properties?) / screen(...) $pageview screen event, path and $screen_name; adjacent duplicates suppressed
useCaptureException() / captureException(error, options?) Explicit JavaScript error with current identity
SPMMobileErrorBoundary Render-error reporting with a host-provided fallback
metric(name, value, options?) Counter, gauge, histogram or timer with platform label
loadFlags(properties?) / useFlag(key, fallback?) Current identity; thirty-second memory cache; reset/revoke clears it
useMobileStatus() Ready/consent/identity/persistence/flag snapshot
flush() Wait for current queued delivery and bounded persistence
shutdown({ flush: false }) Stop and discard; does not change saved consent

Events include usage_platform; errors identify the mobile library. Native crash symbols, native profiling and session replay are separate capabilities. Feature flags control presentation, not server authorization. After cached flags expire, hooks use their fallback until loadFlags() is called again.

The provider attaches one ref-counted AppState listener per client and removes it on unmount, including React StrictMode remounts. Background or active→inactive transitions trigger a best-effort flush. Foreground activity refreshes idle sessions and records the current screen for a new session. Without React, call analytics.attachLifecycle() and retain its cleanup function. Call shutdown() when permanently disposing of a client. React Native AppState.

Event delivery reuses the bounded native transport: up to 1,000 buffered events, 240,000 bytes per batch, configured retry/time limits and explicit HTTP acknowledgement checking. Startup operations are capped at 500; excess calls report through onError. Event queues are memory-only and not durable offline storage. Background delivery cannot be guaranteed after the OS suspends or kills the process; call flush() at meaningful foreground boundaries too.

Verify your installation

Allow collection, open two screens, identify a test user with the same web user ID, and track an action. In Analytics → Live, inspect $pageview, $screen_name, usage_platform, identity and session. Restart to check persistence; background to check flushing; sign out to check identity rotation; opt out and restart to check that collection stays off. Test both iOS and Android in your own release builds. JavaScript unit checks alone do not establish device behavior.

The existing @saaspro/node/native API remains available for hosts that manage identity, consent and lifecycle themselves. New applications can use this package without Node built-ins or browser globals; /core omits the React integration.

Build artifacts and custom frames

JavaScript errors automatically attach registered build debug IDs. Use the build-artifact integration to inject IDs before deployment and privately upload source maps. captureException(error, { frames }) also accepts explicit frames from a custom parser or native bridge, including debugId, architecture, hexadecimal instructionAddress/imageAddress, and optional isReturnAddress. For automatic native capture, install the optional @saaspro/native-crashes add-on, request its separate consent and rebuild the app. Custom native bridges remain supported.

OpenTelemetry error correlation

Set traceContext: () => trace.getActiveSpan()?.spanContext() using your existing @opentelemetry/api instance. Captured errors keep the trace/span IDs that were active when capture was called, including deferred consent or native queues. An explicit captureException(error, { trace: span.spanContext() }) overrides the callback; { trace: null } suppresses correlation for that error. Invalid IDs and throwing callbacks are ignored independently of error capture.

This attaches context only. Configure your OpenTelemetry exporter separately using the trace integration guide. Browser and native clients keep their public ingest key; the trace collector's secret key stays on a server. Open Metrics → Traces for the request timeline or follow View request trace from a captured error.