@tillstack/react-native
GACrash reporting, performance transactions and friction signals for React Native apps, with an offline queue, a daily data cap and on-device PII scrubbing.
Install
npm install @tillstack/react-native @react-native-async-storage/async-storage
# or yarn add ...
cd ios && pod installInstall @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.
Initialise
Call TillPulse.init as the first thing your app does:
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
},
})| Option | Default | Effect |
|---|---|---|
enableNetworkTracking | true | fetch and XHR become breadcrumbs. TillPulse’s own uploads are never recorded. |
networkUrlDenylist | [] | URL substrings never recorded. |
enableFrictionDetection | true | Rage-tap and dead-zone detection from recordTap / recordResponse. |
rageTapThreshold | 3 | Taps in one spot that count as a rage tap. |
deadZoneResponseMs | 300 | A tap with no response inside this window is a dead zone. |
enablePreCrashWatcher | true | Snapshots device state when memory, thermal and battery signals converge. |
enableSessionTracking | true | Session start/end markers for crash-free rates. |
tracesSampleRate | 1 | Default sample rate for transactions (0–1). |
maxDailyDataKB | unlimited | Daily cap on queued data (UTC day). Past it only errors, pre-crash snapshots and session markers are kept. |
disableDefaultPiiScrubbing | false | Turns off on-device scrubbing. Only if you scrub in beforeSend. |
enableThreatDetection | false | Root/jailbreak, debugger and integrity checks. |
enableInstalledAppsScan | false | Android: lists apps holding the overlay permission. Needs user consent. |
initialTags | {} | Tags on every event. |
flushIntervalMs | 30000 | How often the queue drains. |
disableNativeWarning | false | Silences the warning when the native module isn’t linked. |
debug | false | Logs SDK activity to the console. |
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.
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.
Manual capture
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.
Performance transactions
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:
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.
Friction signals
Report taps and visible responses; the SDK turns them into rage-tap and dead-zone breadcrumbs.
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)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().
Offline queue
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.
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.
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.
Expo
Add the config plugin and build a development build. Native capture does not work in Expo Go.
{
"expo": {
"plugins": ["@tillstack/react-native"]
}
}TypeScript
import type {
TillPulseOptions,
TillPulseEvent,
Breadcrumb,
BreadcrumbType,
Severity,
User,
TransactionOptions,
SpanOptions,
} from '@tillstack/react-native'Troubleshooting
Events aren't appearing
- Set
debug: trueand watch the console for[TillPulse]logs. - Check the DSN in the dashboard — settings > SDK Setup.
- If on a corporate network, allow
ingest.tilldev.devthrough 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 installor 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.