TILLPULSE · SDK · REACT NATIVE

@tillstack/react-native

GA

Crash reporting, performance transactions and friction signals for React Native apps, with an offline queue, a daily data cap and on-device PII scrubbing.

01Setup

Install

bash
npm install @tillstack/react-native @react-native-async-storage/async-storage
# or yarn add ...

cd ios && pod install

Install @react-native-async-storage/async-storage too. Events captured offline are encrypted, stored there and flushed on reconnect; without it the queue is memory-only, so a killed app loses them, and out-of-memory detection is off.

02Init

Initialise

Call TillPulse.init as the first thing your app does:

ts
import TillPulse from '@tillstack/react-native'

await TillPulse.init({
  dsn: 'https://<key>@ingest.tilldev.dev/<project-id>',
  release: '1.4.0',           // matches uploaded source maps to events
  environment: 'production',
  dist: '42',                 // build number (optional)

  tracesSampleRate: 0.2,      // share of transactions kept
  maxDailyDataKB: 2048,       // daily cap; errors are always sent

  beforeSend: (event) => {
    if (event.exception?.value.includes('PaymentCancelled')) return null
    return event
  },
})
OptionDefaultEffect
enableNetworkTrackingtruefetch and XHR become breadcrumbs. TillPulse’s own uploads are never recorded.
networkUrlDenylist[]URL substrings never recorded.
enableFrictionDetectiontrueRage-tap and dead-zone detection from recordTap / recordResponse.
rageTapThreshold3Taps in one spot that count as a rage tap.
deadZoneResponseMs300A tap with no response inside this window is a dead zone.
enablePreCrashWatchertrueSnapshots device state when memory, thermal and battery signals converge.
enableSessionTrackingtrueSession start/end markers for crash-free rates.
tracesSampleRate1Default sample rate for transactions (0–1).
maxDailyDataKBunlimitedDaily cap on queued data (UTC day). Past it only errors, pre-crash snapshots and session markers are kept.
disableDefaultPiiScrubbingfalseTurns off on-device scrubbing. Only if you scrub in beforeSend.
enableThreatDetectionfalseRoot/jailbreak, debugger and integrity checks.
enableInstalledAppsScanfalseAndroid: lists apps holding the overlay permission. Needs user consent.
initialTags{}Tags on every event.
flushIntervalMs30000How often the queue drains.
disableNativeWarningfalseSilences the warning when the native module isn’t linked.
debugfalseLogs SDK activity to the console.
03Context

Users, tags and context

Use an opaque id. One that looks like an email address, phone number or IP address is stored only as a stable pseudonym, so it still counts users but can’t be read back.

ts
TillPulse.setUser({ id: hashedUserId, segment: 'pro' })
TillPulse.setTag('plan', 'paid')
TillPulse.setTags({ flavor: 'play-store', ab: 'checkout-v2' })
TillPulse.setContext('cart', { items: 3, total_minor: 5000 })
TillPulse.setExtra('feature_flags', { newCheckout: true })

// On sign-out:
TillPulse.setUser(null)
TillPulse.clearContext()
TillPulse.resetSession()

Tags are searchable and filterable in the dashboard. Context and extras are shown on the event but not indexed.

04Capture

Manual capture

ts
try {
  await processPayment()
} catch (err) {
  TillPulse.captureError(err)            // severity defaults to 'high'
}

TillPulse.captureMessage('Validation failed', 'medium')

Both return the event id. Severities: critical, high, medium, low, info. For per-event tags, set them just before capturing or rewrite the event in beforeSend.

06Navigation

React Navigation

Record screen changes from the container:

tsx
import { useRef } from 'react'
import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native'
import TillPulse from '@tillstack/react-native'

export function App() {
  const navigationRef = useNavigationContainerRef()
  const current = useRef<string | undefined>(undefined)

  return (
    <NavigationContainer
      ref={navigationRef}
      onReady={() => { current.current = navigationRef.getCurrentRoute()?.name }}
      onStateChange={() => {
        const next = navigationRef.getCurrentRoute()?.name
        if (next && next !== current.current) {
          TillPulse.addBreadcrumb({
            type: 'navigation',
            category: 'navigation',
            message: next,
            data: { from: current.current },
          })
        }
        current.current = next
      }}
    >
      {/* ... */}
    </NavigationContainer>
  )
}
07Performance

Performance transactions

ts
const tx = TillPulse.startTransaction({ name: 'checkout', op: 'flow', screen: 'Cart' })
const span = tx.startSpan({ op: 'http.client', description: 'POST /charge' })

try {
  await charge()
  span.finish()
  tx.finish()
} catch (e) {
  span.finish('error')
  tx.finish('error')
  throw e
}

Or let the SDK finish it, with error if the body throws:

ts
const order = await TillPulse.withTransaction({ name: 'checkout', op: 'flow' }, async (tx) => {
  const span = tx.startSpan({ op: 'db.query', description: 'load cart' })
  const cart = await loadCart()
  span.finish()
  return placeOrder(cart)
})

Statuses: ok (default), error, cancelled, unknown. sampleRate on a transaction overrides tracesSampleRate. Cold start and frame jank are measured natively.

08Friction

Friction signals

Report taps and visible responses; the SDK turns them into rage-tap and dead-zone breadcrumbs.

tsx
import { recordTap, recordResponse, recordFormAbandon } from '@tillstack/react-native'

<Pressable
  onPress={(e) => {
    recordTap(e.nativeEvent.pageX, e.nativeEvent.pageY, 'BuyButton')
    buy()
    recordResponse()  // something visible happened
  }}
/>

// A form left part-way: name, fields completed, total fields.
recordFormAbandon('checkout_address', 3, 8)
09Native

Native crash + jank

The native module installs uncaught-exception handlers (NSSetUncaughtExceptionHandler on iOS, Thread.setDefaultUncaughtExceptionHandler on Android) and measures cold start and frame jank. A session that ends without a clean shutdown is reported on the next launch as PreviousSessionEndedUnexpectedly. Check wiring at runtime with TillPulse.hasNativeCrashHandler() or TillPulse.nativeBridgeStatus().

10Offline

Offline queue

ts
await TillPulse.flush()                          // before a known-disruptive operation
const { size, totalBytes } = TillPulse.getQueueStats()

Up to 500 events or 5 MB, lowest priority dropped first; errors are sent before anything else. Backoff is exponential up to 6 attempts and honours Retry-After. Stored events expire after 7 days.

The stored queue is encrypted. On Android it is sealed with AES-256-GCM under a data key wrapped by an Android Keystore key that can't be exported (in StrongBox when the device has one). On iOS it is sealed with AES-256 and authenticated with HMAC-SHA256 under a keychain key that never syncs or leaves the device. The key never reaches JavaScript, so another library reading AsyncStorage sees only ciphertext, and an edited or swapped queue fails authentication and is discarded instead of sent. A queue written by an older SDK version is read once and rewritten encrypted. Without the native module (Expo Go, or a build that didn't link it) nothing is written to disk: events stay in memory until they are sent.

11Privacy

PII scrubbing

Every event is scrubbed on the device before it is queued:

  • Values under secret-looking keys (password, token, authorization, …)
  • API keys and tokens with a known shape (cloud, GitHub, Slack, JWTs, bearer tokens)
  • Email addresses, payment card numbers, national-ID and phone-number patterns
  • Mobile-money transaction references
  • IP addresses

Before anything is stored, your workspace’s data policy masks the same classes again, by default, plus any patterns you add. In a workspace that uses TillSecrets, it also replaces any value held in the vault with [REDACTED:vault-secret] and tells your admins. See Leaked values.

12Symbols

Symbolication

Release builds report minified JavaScript, obfuscated Android and address-only iOS frames. Upload symbols for every release and TillPulse resolves them on the server:

  • JS source maps — POST /api/projects/:id/source-maps
  • Android ProGuard mapping — same endpoint, symbolication_type=proguard
  • iOS dSYMs — tilldev pulse dsym upload <id> --release <v> --file <.xcarchive or .dSYM>; native frames resolve to function, file and line

See Source maps & symbolication for the full guide.

13Expo

Expo

Add the config plugin and build a development build. Native capture does not work in Expo Go.

app.json
{
  "expo": {
    "plugins": ["@tillstack/react-native"]
  }
}
14Types

TypeScript

ts
import type {
  TillPulseOptions,
  TillPulseEvent,
  Breadcrumb,
  BreadcrumbType,
  Severity,
  User,
  TransactionOptions,
  SpanOptions,
} from '@tillstack/react-native'
15Support

Troubleshooting

Events aren't appearing

  • Set debug: true and watch the console for [TillPulse] logs.
  • Check the DSN in the dashboard — settings > SDK Setup.
  • If on a corporate network, allow ingest.tilldev.dev through the firewall.
  • Call TillPulse.flush() — the queue drains every 30s by default.
  • A startup warning about the native module means it isn't linked; rebuild after pod install or the Expo plugin.

Stack frames are obfuscated

Upload symbols. See Source maps.

Rate-limited

Each project has an hourly ingest cap. Reduce noise via beforeSend or maxDailyDataKB, or raise the cap on the project's settings page.