A practical, low-risk path to observable React Native apps. This pilot captures JavaScript exceptions, startup timing, UI freezes, network performance, and purchase outcomes. It defines alert rules and dashboards tied to user experience, and verifies everything locally before broad rollout. The modular TypeScript module, server-side alerting, and operational routines keep noise low and build confidence.
Prerequisites, Versions, and Assumptions
- React Native ≥ 0.73 (Hermes default), Node ≥ 18, TypeScript ≥ 5.0, @react-native-async-storage/async-storage ≥ 1.21
- Platforms: iOS 13+ / Android API 24+; Debug and Release builds; Expo SDK 50+ (managed and dev client)
- Single JS bundle, single process, no CodePush in pilot (CodePush requires session reset on bundle swap)
- All collectors run on the JS thread; no native bridge in pilot. Native crash correlation is covered in the Extension Path.
Architecture Overview
App → Monitoring Module (Collectors → Redactor → Buffer → Flusher) → Transport → Ingestion API
Data flow: Session start → event enqueue → periodic/background flush → server aggregation → alerts/dashboards. Threading stays on the JS thread; native crash correlation is added later. The module exposes a small, typed API so product code stays clean.
Configuration and Feature Flags
Remote config (monitoring.config.json) controls everything at runtime:
{
"endpoint": "https://example.com/monitoring",
"flushIntervalMs": 5000,
"maxBuffer": 500,
"maxBatchSizeBytes": 262144,
"uiFreezeThresholdMs": 500,
"uiFreezeIntervalMs": 100,
"piiPatterns": [
"\\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}\\b",
"\\b\\d{12,19}\\b",
"Bearer\\s+[A-Za-z0-9\\-._~+/]+=*",
"eyJ[A-Za-z0-9_-]*\\.[A-Za-z0-9_-]*\\.[A-Za-z0-9_-]*",
"\\b(-?\\d+(\\.\\d+)?),\\s*(-?\\d+(\\.\\d+)?)\\b"
],
"piiAllowlist": ["user_id", "device_id"],
"enabledCollectors": ["jsExceptions", "startup", "uiFreeze", "network", "purchase"],
"samplingRate": 1.0,
"enableCompression": true,
"hmacKeyId": "prod-key-1",
"monitoringEnabled": true
}
Local override: store partial config in AsyncStorage key monitor:config_override for development.
Instrumentation API (TypeScript)
Core Types
export interface MonitoringConfig {
endpoint: string;
flushIntervalMs: number;
maxBuffer: number;
maxBatchSizeBytes: number;
uiFreezeThresholdMs: number;
uiFreezeIntervalMs: number;
piiPatterns: string[];
piiAllowlist: string[];
enabledCollectors: CollectorName[];
samplingRate: number;
enableCompression: boolean;
hmacKeyId: string;
monitoringEnabled: boolean;
}
export type CollectorName = 'jsExceptions' | 'startup' | 'uiFreeze' | 'network' | 'purchase';
export interface Event {
ts: number;
session_id: string;
type: 'event' | 'metric' | 'log';
level: 'info' | 'warn' | 'error';
name: string;
fields: Record<string, unknown>;
app: AppContext;
rn: RNContext;
device: DeviceContext;
event_id: string;
}
Context objects (AppContext, RNContext, DeviceContext) carry version, bundle ID, Hermes flag, platform, OS version, and model. Every event includes a client-generated UUID event_id for idempotent ingestion.
Public Methods
export const Monitoring = {
init: (config: Partial<MonitoringConfig>) => Promise<void>;
markAppReady: (screen: string) => void;
trackPurchaseAttempt: (fields: PurchaseEventFields) => void;
trackPurchaseSuccess: (fields: PurchaseEventFields) => void;
trackPurchaseFailure: (fields: PurchaseEventFields) => void;
recordEvent: (name: string, fields: Record<string, unknown>, level?: Event['level'], type?: Event['type']) => void;
setEnabled: (flag: boolean) => void;
disableCollector: (name: CollectorName) => void;
flush: () => Promise<void>;
closeSession: () => Promise<void>;
getDebugState: () => DebugState; // for MonitoringDebugPanel
};
PurchaseEventFields uses minor units (cents) and ISO 4217 currency. Failure reasons map to actionable buckets: authentication_required, insufficient_funds, network_error, cancelled, invalid_payment_method, unknown.
Collectors Deep Dive
JavaScript Exceptions
- Mechanism:
ErrorUtils.setGlobalHandlerplus aconsole.errorproxy. - Hermes note: Global handler works in Hermes 0.73+; minified stacks preserve frame structure but lose variable names. Upload source maps to the ingestion pipeline for symbolication.
- Fatal vs non-fatal:
isFatalflag fromErrorUtils;console.errorcaptures non-fatal React warnings.
Startup Timing
tAppStart: Captured at module load viaperformance.now()(monotonic). IfPerformance.markis available, markapp_start.markAppReady(screen): Emitsapp.startupwithphase: 'first_screen_ready',startup_ms, andscreen.- Cold vs warm: Detected via
session.previous_unclean_exitflag (true if prior session did not close cleanly).
UI Freeze Detection
- Algorithm:
requestAnimationFrame/setTimeouthybrid loop measuring frame budget. - Config:
uiFreezeThresholdMs(default 500),uiFreezeIntervalMs(default 100). - Per-frame budget: 16.67 ms at 60 fps; lag =
now - last - intervalMs.
Network Monitoring
- Fetch wrapper: Clones
Requestfor body reading if needed; captures method, sanitized URL, status, duration, request/response byte counts. - Redaction: Strips
Authorization,Cookie,X-Api-Keyheaders; scrubs query params matchingtoken|key|secret|sig. - AbortSignal: Propagates abort; records
net.request_abortedwith duration.
Purchase Flow
- Provider enum:
stripe | gplay | iap | other. - Amount in minor units (cents). Currency: ISO 4217.
- Failure taxonomy:
authentication_required,insufficient_funds,network_error,cancelled,invalid_payment_method,unknown.
Transport and Reliability
Buffer
- Structure: Ring buffer persisted to AsyncStorage (
monitor:buffer) plus in-memory head/tail. - Persistence: On enqueue, serialize buffer to AsyncStorage (debounced 500 ms). On init, hydrate from storage.
- Limits:
maxBufferevents (default 500) ormaxBatchSizeBytes(default 256 KB), whichever is reached first.
Flush
- Schedule: Interval
flushIntervalMs(default 5 s) plusAppStatebackground trigger. - Backoff: Exponential base 1 s, cap 60 s, jitter ±25%.
- Batch: ≤ 50 events or 256 KB; gzip
Content-Encoding: gzipwhenenableCompression=true. - Idempotency: Client-generated
event_id(UUID v4 viacrypto.randomUUID()with polyfill for RN).
Kill Switch
Remote config monitoring_enabled=false → immediate flush(), stop all collectors, clear buffer, disable network calls.
Alerting and Dashboards (Operational)
PromQL Alert Rules
# High JS exception rate
- alert: ReactNativeHighJSExceptionRate
expr: |
sum(rate(rn_js_exception_total[5m])) by (app_version)
/
sum(rate(rn_session_start_total[5m])) by (app_version)
> 0.01
for: 10m
labels:
severity: critical
team: mobile-oncall
annotations:
runbook_url: "https://example.com/runbooks/react-native-js-errors"
summary: "JS exception rate > 1% for {{ $labels.app_version }}"
# UI freeze surge
- alert: ReactNativeUIFreezeSurge
expr: |
sum(rate(rn_ui_freeze_total{lag_ms>700}[5m])) by (device_model)
> 50
for: 5m
labels:
severity: warning
team: mobile-oncall
annotations:
runbook_url: "https://example.com/runbooks/react-native-ui-freezes"
summary: "UI freeze surge on {{ $labels.device_model }}"
# API latency regression
- alert: ReactNativeAPILatencyRegression
expr: |
histogram_quantile(0.95, sum(rate(rn_net_request_duration_seconds_bucket{url=~"api\\.example\\.com.*"}[15m])) by (le, endpoint))
> 1
for: 15m
labels:
severity: warning
team: backend-oncall,mobile-oncall
annotations:
runbook_url: "https://example.com/runbooks/api-latency"
summary: "API p95 > 1s for {{ $labels.endpoint }}"
# Purchase failure spike
- alert: ReactNativePurchaseFailureSpike
expr: |
sum(rate(rn_purchase_failure_total[15m])) by (provider)
/
sum(rate(rn_purchase_attempt_total[15m])) by (provider)
> 0.1
for: 15m
labels:
severity: critical
team: payments-oncall
annotations:
runbook_url: "https://example.com/runbooks/purchase-failures"
summary: "Purchase failure rate > 10% for {{ $labels.provider }}"
Grafana Dashboard Panels
- JS Exception Rate by Version (timeseries)
- Previous Unclean Exits by Device (barchart)
- Startup p50/p95 (timeseries)
- UI Freezes Heatmap: Device × Lag (heatmap)
- API Latency by Endpoint (timeseries)
- Purchase Funnel: Attempt / Success / Failure by Provider and Reason (barchart)
Verification and Testing
Local Event Inspection
- JS exception: Press test button →
throw new Error('test')→ verifyjs.exceptionwith stack,isFatal=false. - Console error:
console.error('test error')→ verifyjs.console_error. - Startup: Cold launch → verify
app.startupwithstartup_msin device-expected range. - UI freeze: Debug button blocks JS thread 1 s (
const t=Date.now(); while(Date.now()-t<1000){}) → verifyui.freezewithlag_ms ~ 1000. - Network: Fetch healthy/fail endpoints → verify
net.requestwith sanitized URL, status, duration. - Purchase: Trigger attempt/success/failure → verify
purchase.attempt,purchase.success,purchase.failurewith provider, amount_cents, reason. - No server? Temporarily set
flush()toconsole.log(JSON.stringify({events: payload}))and validate shapes/scrubbing.
Aggregation Sanity Checks
- Session attribution: Every event includes
session_id.previous_unclean_exitappears once at session start when prior session not closed. - Rate denominators: Emit explicit
session.startevent for reliable denominator. If absent, approximate by distinctsession_idper day. - PII scrubbing: Inject
[email protected]in field → confirm[redacted_email].
Platform and Release Checks
- Android Release: Minified stacks preserved in
js.exception; native crashes not captured (unclean-exit heuristic helps). - iOS Release: Background transition (
AppStatechange) triggers flush. - Expo managed:
Monitoring.init()in entry; console proxy works in dev/prod. Withexpo-dev-client, native modules available for future native crash capture.
Unit Tests (monitoring.test.ts)
import { Monitoring, redactPII, createBuffer } from './monitoring';
describe('PII redaction', () => {
test('redacts email', () => expect(redactPII('[email protected]')).toBe('[redacted_email]'));
test('redacts credit card (Luhn-valid)', () => expect(redactPII('4242 4242 4242 4242')).toBe('[redacted_numeric]'));
test('redacts JWT', () => expect(redactPII('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c')).toContain('[redacted_jwt]'));
test('preserves allowlisted keys', () => {
const obj = { user_id: '123', email: '[email protected]' };
expect(redactPII(obj)).toEqual({ user_id: '123', email: '[redacted_email]' });
});
});
describe('Buffer eviction', () => {
test('drops oldest when maxBuffer exceeded', () => {
const buf = createBuffer({ maxBuffer: 3 });
buf.push({id:1}); buf.push({id:2}); buf.push({id:3}); buf.push({id:4});
expect(buf.toArray().map(e=>e.id)).toEqual([2,3,4]);
});
});
describe('Flush retry/backoff', () => {
test('exponential backoff with jitter', async () => {
const flush = jest.fn().mockRejectedValueOnce(new Error('net')).mockResolvedValueOnce(undefined);
const { flushWithBackoff } = await import('./monitoring');
await flushWithBackoff(flush, { baseMs: 100, capMs: 1000, jitter: 0 });
expect(flush).toHaveBeenCalledTimes(2);
});
});
describe('Session unclean detection', () => {
test('emits previous_unclean_exit when prior session not closed', async () => {
await AsyncStorage.setItem('monitor:last_session', JSON.stringify({ id: 'old', startedAt: 1, closed: false }));
const events: any[] = [];
Monitoring.recordEvent = (name, fields) => events.push({name, fields});
await Monitoring.init({});
expect(events.find(e=>e.name==='session.previous_unclean_exit')).toBeTruthy();
});
});
describe('Collector enable/disable', () => {
test('disableCollector stops UI freeze detector', () => {
Monitoring.disableCollector('uiFreeze');
// verify no ui.freeze events emitted during simulated lag
});
});
E2E (Detox) Spec (monitoring.e2e.ts)
describe('Monitoring E2E', () => {
beforeAll(async () => { await device.launchApp({newInstance: true, delete: true}); });
test('cold start emits app.startup', async () => {
const events = await getMonitoringEvents();
expect(events.find(e => e.name === 'app.startup' && e.fields.phase === 'first_screen_ready')).toBeTruthy();
});
test('background triggers flush', async () => {
await device.sendToHome();
await waitForFlush();
const events = await getMonitoringEvents();
expect(events.find(e => e.name === 'session.background_flush')).toBeTruthy();
});
test('unclean exit detected on next launch', async () => {
await device.terminateApp();
await device.launchApp({newInstance: true});
const events = await getMonitoringEvents();
expect(events.find(e => e.name === 'session.previous_unclean_exit')).toBeTruthy();
});
});
CI gate: npm run test:monitoring in pipeline (runs Jest + Detox on emulator/simulator).
Debug Panel (MonitoringDebugPanel.tsx)
A React Native component that shows live state and lets QA trigger test events. All buttons have accessibilityLabel; panel works with TalkBack/VoiceOver.
import React from 'react';
import { View, Text, Button, ScrollView, StyleSheet } from 'react-native';
import { Monitoring } from './monitoring';
export const MonitoringDebugPanel = () => {
const [state, setState] = React.useState(Monitoring.getDebugState());
React.useEffect(() => {
const id = setInterval(() => setState(Monitoring.getDebugState()), 1000);
return () => clearInterval(id);
}, []);
return (
<ScrollView style={styles.container} accessibilityLabel="Monitoring Debug Panel">
<Text style={styles.title}>Monitoring Debug</Text>
<Text>Enabled: {String(state.enabled)}</Text>
<Text>Buffer: {state.bufferLength}/{state.maxBuffer}</Text>
<Text>Session: {state.sessionId}</Text>
<Button title="Throw Test Error" onPress={() => { throw new Error('test'); }} accessibilityLabel="Throw test error" />
<Button title="Console Error" onPress={() => console.error('test error')} accessibilityLabel="Log console error" />
<Button title="Simulate UI Freeze" onPress={() => { const t=Date.now(); while(Date.now()-t<1000){} }} accessibilityLabel="Simulate UI freeze" />
<Button title="Fetch Healthy" onPress={() => fetch('https://httpbin.org/get')} accessibilityLabel="Fetch healthy endpoint" />
<Button title="Fetch Fail" onPress={() => fetch('https://httpbin.org/status/500')} accessibilityLabel="Fetch failing endpoint" />
<Button title="Purchase Attempt" onPress={() => Monitoring.trackPurchaseAttempt({provider:'stripe', amount_cents: 1000})} accessibilityLabel="Simulate purchase attempt" />
<Button title="Flush Now" onPress={() => Monitoring.flush()} accessibilityLabel="Flush buffer now" />
<Button title="Toggle Enabled" onPress={() => Monitoring.setEnabled(!state.enabled)} accessibilityLabel="Toggle monitoring enabled" />
</ScrollView>
);
};
const styles = StyleSheet.create({ container: { padding: 16 }, title: { fontSize: 18, fontWeight: '600', marginBottom: 12 } });
Security and Privacy
PII Categories Handled
- Email: Regex plus allowlist keys (
user_id,device_id). - Credit card: Luhn validation optional (enable via
piiPatterns). - JWT: Three-segment base64url pattern.
- Bearer tokens:
Authorization: Bearer <token>header scrubbing. - GPS coordinates:
lat,lonpattern in strings. - IP addresses: Server-side scrubbing (not client).
Data Retention
- Client:
SESSION_TTL_MS(24 h) → discard stale events on init. - Server: 30 days raw events, 13 months aggregated metrics.
Consent and Compliance
- Map
scrub_piiflag to consent state; GDPR/CCPA delete endpoint reference in runbook. - Transport security: Pin endpoint via remote config; validate TLS; sign payloads with HMAC-SHA256 (
hmacKeyIdin config) to reject tampering. - Secrets: Never log headers/query params in
net.request; scrub before enqueue.
Performance and Overhead Budget
| Metric | Target | Measurement |
|---|---|---|
| Collector overhead | < 1 ms/frame idle | react-native-performance / Systrace |
| RAM buffer | < 2 MB | Flipper React DevTools memory profiler |
| Battery impact | < 5% over 24 h | Android Battery Historian / iOS Energy Log |
| Network payload | ≤ 256 KB/batch | maxBatchSizeBytes config |
Profiling steps:
npx react-native-performance→ record 30 s trace.- Open in Perfetto/Chrome DevTools → filter Monitoring module.
- Verify no frame drops > 16 ms during flush.
Failure Modes, Troubleshooting, and Rollback
| Symptom | Cause | Detection | Mitigation | Rollback Step |
|---|---|---|---|---|
| Frame drops > 16 ms | UI freeze detector too frequent | Systrace shows setInterval / rAF spikes | Increase uiFreezeIntervalMs to 200 ms; disable via remote config | disableCollector('uiFreeze') |
| Buffer OOM | Offline > 24 h, maxBuffer too high | AsyncStorage size > 5 MB | Lower maxBuffer to 200; persist only last 50 KB | Clear monitor:buffer in AsyncStorage |
| PII in logs | Redaction pattern missed | Log audit finds email/token | Add pattern to piiPatterns; redeploy config | Hotfix config via remote config |
| Alert fatigue | Thresholds too tight | > 10 alerts/day per rule | Multiply threshold by 3× p99 baseline | Adjust PromQL rule threshold |
| Native crash invisible | JS-only pilot | previous_unclean_exit spikes, no js.exception | Integrate react-native-exception-handler or vendor SDK | Correlate via session_id |
| Time skew | Device clock drift | ts vs server receipt delta > 5 min | Use performance.now() for durations; server corrects | N/A (server-side) |
Native Crash Gap
Add react-native-exception-handler (or vendor SDK) and emit native.crash with same session_id. Correlate in dashboard.
Time Skew
Durations use performance.now() (monotonic). Server applies server_received_ts - client_ts correction for absolute timestamps.
Realistic Technical Scenario: Black Friday Purchase Spike
Conditions: 5× traffic, Stripe 3DS challenges, Android low-end UI freezes, API p95 degradation.
Walkthrough:
- Alert fires:
ReactNativePurchaseFailureSpike(failure rate 18% Stripe) +ReactNativeAPILatencyRegression(catalog p95 2.1 s). - Dashboard triage: Purchase funnel shows
authentication_required60% of failures; API latency heatmap shows/v1/catalogregression on Android 10 devices. - Mitigation:
- Feature flag
disable_heavy_animation→ reduces UI freezes on low-end chipsets. - Remote config: increase
flushIntervalMsto 10 s, enableenableCompressionto reduce network contention. - Backend: add cache layer for catalog; Stripe: verify 3DS flow client-side.
- Post-incident: Collect 7 days pilot data → set new thresholds at 3× p99 baseline; update runbook with 3DS troubleshooting steps.
Operations Checklists
Daily
- Review Stability dashboard: JS exception rate trends by latest
app_version. - Check Performance: API p95 for primary endpoints; watch regressions vs yesterday.
- Triage Alerts: Acknowledge, add context, link to runbooks.
Weekly
- Tune thresholds: Reduce noise, add dimensions (device model, OS version).
- Backlog review: Investigate top 3 exception signatures; add guardrails/fixes.
- Data health: Sample event payloads → verify fields, scrubbing, session attribution.
Per Release
- Compare beta vs prior production: Exception rate, startup p50/p95, API p95.
- Spot-check low-end Android devices for UI freeze outliers.
- Validate purchase flow outcomes by provider; ensure failure reasons map to actionable buckets.
Extension Path
- Native crash capture: Integrate
react-native-exception-handleror vendor SDK; tie tosession_id. - Navigation/screen metrics: Record
navigation.transitionwithduration_ms,from,to. - Foreground/background: Track
app.foreground_duration_s,session.countby release channel. - Sampling: 1% verbose logs (full stack, Redux actions) via
samplingRateconfig.
Conclusion
This pilot delivers a practical, low-risk path to monitor a React Native app: capture JavaScript exceptions, startup timing, UI freezes, network performance, and purchase outcomes; define alert rules and dashboards aligned to user experience; and verify everything locally before broad rollout. Start with the pilot, validate signals and scrubbing, then grow coverage and tighten thresholds steadily. The modular TypeScript module, server-side alerting, and operational routines keep noise low, build confidence, and make your React Native monitoring program effective and maintainable.