Skip to content

Commit 22b2be7

Browse files
authored
feat: improve exception instrumentation in react-native SDK (#177)
## Summary Support automated exception reporting for uncaught exceptions, promise rejections and network errors. Automatically capture common exceptions reported by applications. ## How did you test this change? testing in simulator and on native iphone ios https://www.loom.com/share/53093f408f094444ae88d3b116d97a07?from_recorder=1&focus_title=1 ## Are there any deployment considerations? changeset, new react native version
1 parent e3dce20 commit 22b2be7

File tree

22 files changed

+2092
-199
lines changed

22 files changed

+2092
-199
lines changed

.changeset/great-comics-ask.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@launchdarkly/observability-react-native': minor
3+
---
4+
5+
support automated exception reporting for uncaught exceptions, promise rejections and network errors

.changeset/pretty-nails-reply.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'highlight.run': patch
3+
---
4+
5+
update export timeouts for highlight.run

.changeset/shaggy-comics-unite.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@launchdarkly/observability-react-native': minor
3+
---
4+
5+
add additional error handlers for the react native sdk

e2e/react-native-otel/app/(tabs)/index.tsx

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ export default function HomeScreen() {
5959
component: 'HomeScreen',
6060
},
6161
})
62+
63+
// Test that error reporting is working
64+
console.log(
65+
'Testing error reporting with a message to verify console error handler',
66+
)
6267
}, [])
6368

6469
const handleTestError = () => {
@@ -280,6 +285,184 @@ export default function HomeScreen() {
280285
)
281286
}
282287

288+
// Exception test handlers
289+
const handleUncaughtException = () => {
290+
// This will cause an uncaught exception that should be caught by the global handler
291+
setTimeout(() => {
292+
// Using setTimeout to ensure it's not caught by React's error boundary
293+
const obj: any = null
294+
obj.nonExistentMethod() // Will throw TypeError: Cannot read property 'nonExistentMethod' of null
295+
}, 100)
296+
}
297+
298+
const handleUnhandledPromiseRejection = () => {
299+
// This creates an unhandled Promise rejection
300+
setTimeout(() => {
301+
// Using setTimeout to escape from the current execution context
302+
try {
303+
// Direct rejection using Promise.reject - this should be detected
304+
Promise.reject(new Error('Unhandled Promise Rejection Test'))
305+
306+
// Also try the constructor form
307+
new Promise((resolve, reject) => {
308+
// Explicit rejection without a catch handler
309+
reject(new Error('Unhandled Promise Constructor Rejection'))
310+
})
311+
312+
// Also try throwing in a Promise - this is another common case
313+
new Promise(() => {
314+
throw new Error('Error thrown inside Promise constructor')
315+
})
316+
317+
// Log to confirm execution
318+
console.log(
319+
'Unhandled Promise rejection triggered - check for [LD-O11Y] logs',
320+
)
321+
} catch (e) {
322+
// This shouldn't catch the Promise rejections
323+
console.log(
324+
'This catch should not execute for Promise rejections',
325+
e,
326+
)
327+
}
328+
}, 0)
329+
}
330+
331+
const handleRecursiveError = () => {
332+
// This creates a stack overflow error
333+
// Wrap in setTimeout to bypass React's error boundaries
334+
setTimeout(() => {
335+
const recursiveFunction = (depth: number = 0): number => {
336+
// Add some protection to prevent actually crashing the app
337+
if (depth > 5000) {
338+
throw new Error(
339+
'Maximum recursion depth reached (stack overflow simulation)',
340+
)
341+
}
342+
return recursiveFunction(depth + 1)
343+
}
344+
345+
recursiveFunction()
346+
}, 0)
347+
}
348+
349+
const handleAsyncError = async () => {
350+
// This creates an error in an async function
351+
// Use setTimeout to ensure we're out of the current execution context
352+
setTimeout(() => {
353+
// Direct async/await error
354+
;(async () => {
355+
try {
356+
// First approach: async function throwing directly
357+
const makeAsyncError = async () => {
358+
await new Promise((resolve) => setTimeout(resolve, 200))
359+
console.log('About to throw from async function...')
360+
throw new Error('Async operation failed')
361+
}
362+
363+
// Run without awaiting or catching the error to ensure it becomes unhandled
364+
makeAsyncError()
365+
366+
// Second approach: rejected promise in async function
367+
const makeAsyncRejection = async () => {
368+
await new Promise((resolve) => setTimeout(resolve, 300))
369+
console.log('About to reject from async function...')
370+
await Promise.reject(
371+
new Error('Async promise rejection'),
372+
)
373+
}
374+
375+
// Run without catching
376+
makeAsyncRejection()
377+
378+
// Third approach: await a throwing function
379+
const delayedThrow = () =>
380+
new Promise((_, reject) => {
381+
setTimeout(() => {
382+
console.log(
383+
'About to reject from delayed function...',
384+
)
385+
reject(
386+
new Error(
387+
'Delayed rejection in async context',
388+
),
389+
)
390+
}, 400)
391+
})
392+
393+
// This would be caught if we awaited it, but we don't
394+
delayedThrow()
395+
396+
// Add a log to help track when the errors should occur
397+
console.log(
398+
'Async errors scheduled - watch for [LD-O11Y] logs',
399+
)
400+
} catch (e) {
401+
// This shouldn't execute for the unhandled async errors
402+
console.log(
403+
'This catch should not execute for async errors',
404+
e,
405+
)
406+
}
407+
})()
408+
}, 0)
409+
}
410+
411+
const handleNetworkError = () => {
412+
// This creates a network error by attempting to fetch from a non-existent URL
413+
setTimeout(() => {
414+
console.log('Triggering network error to non-existent domain')
415+
416+
// Multiple network error approaches
417+
418+
// 1. Completely invalid URL - this will cause a synchronous error
419+
try {
420+
// This would normally be caught by React's error boundary, but we want to throw it directly
421+
fetch('invalid://malformed-url').catch((error) => {
422+
// We handle this one so the test can continue
423+
console.log('Caught malformed URL error:', error.message)
424+
LDObserve.recordError(error, {
425+
source: 'network_test',
426+
type: 'malformed_url',
427+
})
428+
})
429+
} catch (e) {
430+
console.log('Synchronous fetch error (malformed URL):', e)
431+
}
432+
433+
// 2. Non-existent domain - this will cause a network error
434+
fetch('https://non-existent-domain-123456789.xyz').then(
435+
(response) => response.json(),
436+
)
437+
// Intentionally no catch handler to make it unhandled
438+
439+
// 3. Timeout error - this will cause a timeout
440+
const controller = new AbortController()
441+
const timeoutId = setTimeout(() => controller.abort(), 100)
442+
443+
fetch('https://httpstat.us/200?sleep=5000', {
444+
signal: controller.signal,
445+
}).then((response) => {
446+
clearTimeout(timeoutId)
447+
return response.json()
448+
})
449+
// Intentionally no catch handler to make it unhandled
450+
451+
// 4. Server error - this should return a non-200 status code
452+
fetch('https://httpstat.us/500').then((response) => {
453+
if (!response.ok) {
454+
throw new Error(
455+
`Server responded with status: ${response.status}`,
456+
)
457+
}
458+
return response.json()
459+
})
460+
// Intentionally no catch handler to make it unhandled
461+
462+
console.log('Network errors triggered - watch for [LD-O11Y] logs')
463+
}, 0)
464+
}
465+
283466
return (
284467
<ParallaxScrollView
285468
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
@@ -344,6 +527,55 @@ export default function HomeScreen() {
344527
Test Span Hierarchy
345528
</ThemedText>
346529
</Pressable>
530+
531+
<ThemedText type="subtitle" style={{ marginTop: 16 }}>
532+
Error Testing
533+
</ThemedText>
534+
535+
<Pressable
536+
style={[styles.button, styles.errorButton]}
537+
onPress={handleUncaughtException}
538+
>
539+
<ThemedText style={styles.buttonText}>
540+
Trigger Uncaught Exception
541+
</ThemedText>
542+
</Pressable>
543+
544+
<Pressable
545+
style={[styles.button, styles.errorButton]}
546+
onPress={handleUnhandledPromiseRejection}
547+
>
548+
<ThemedText style={styles.buttonText}>
549+
Trigger Unhandled Promise Rejection
550+
</ThemedText>
551+
</Pressable>
552+
553+
<Pressable
554+
style={[styles.button, styles.errorButton]}
555+
onPress={handleRecursiveError}
556+
>
557+
<ThemedText style={styles.buttonText}>
558+
Trigger Stack Overflow Error
559+
</ThemedText>
560+
</Pressable>
561+
562+
<Pressable
563+
style={[styles.button, styles.errorButton]}
564+
onPress={handleAsyncError}
565+
>
566+
<ThemedText style={styles.buttonText}>
567+
Trigger Async Error
568+
</ThemedText>
569+
</Pressable>
570+
571+
<Pressable
572+
style={[styles.button, styles.errorButton]}
573+
onPress={handleNetworkError}
574+
>
575+
<ThemedText style={styles.buttonText}>
576+
Trigger Network Error
577+
</ThemedText>
578+
</Pressable>
347579
</ThemedView>
348580

349581
<ThemedView style={styles.stepContainer}>
@@ -407,6 +639,9 @@ const styles = StyleSheet.create({
407639
marginVertical: 4,
408640
alignItems: 'center',
409641
},
642+
errorButton: {
643+
backgroundColor: '#FF3B30', // Red color for error buttons
644+
},
410645
buttonText: {
411646
color: 'white',
412647
fontWeight: 'bold',

e2e/react-native-otel/app/_layout.tsx

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import { useColorScheme } from '@/hooks/useColorScheme'
1414
import { initializeLaunchDarkly } from '@/lib/launchdarkly'
1515
import { LDObserve } from '@launchdarkly/observability-react-native'
1616
import Constants from 'expo-constants'
17-
import { Platform } from 'react-native'
17+
import { Platform, LogBox } from 'react-native'
18+
19+
// Enable detailed promise rejection logs - these are normally suppressed in React Native
20+
LogBox.ignoreLogs(['Possible Unhandled Promise Rejection'])
1821

1922
// Prevent the splash screen from auto-hiding before asset loading is complete.
2023
SplashScreen.preventAutoHideAsync()
@@ -41,6 +44,93 @@ export default function RootLayout() {
4144
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
4245
})
4346

47+
// Add global error handlers directly in the React component
48+
useEffect(() => {
49+
// Direct error handler for promise rejections
50+
const errorHandler = (error: any) => {
51+
console.log('[RootLayout] Caught unhandled error:', error)
52+
if (error instanceof Error) {
53+
LDObserve.recordError(error, {
54+
'error.unhandled': true,
55+
'error.caught_by': 'root_component_handler',
56+
})
57+
}
58+
}
59+
60+
// Register a direct listener for unhandled rejections at the app level
61+
const rejectionHandler = (event: any) => {
62+
const error = event.reason || new Error('Unknown promise rejection')
63+
console.log(
64+
'[RootLayout] Caught unhandled promise rejection:',
65+
error,
66+
)
67+
LDObserve.recordError(
68+
error instanceof Error ? error : new Error(String(error)),
69+
{
70+
'error.unhandled': true,
71+
'error.caught_by': 'root_component_promise_handler',
72+
'promise.handled': false,
73+
},
74+
)
75+
}
76+
77+
// Network error handler to catch fetch errors
78+
const networkErrorHandler = (error: any) => {
79+
console.log('[RootLayout] Caught network error:', error)
80+
LDObserve.recordError(
81+
error instanceof Error ? error : new Error(String(error)),
82+
{
83+
'error.unhandled': true,
84+
'error.caught_by': 'root_component_network_handler',
85+
'error.type': 'network',
86+
},
87+
)
88+
}
89+
90+
// Set up the handlers
91+
if (global.ErrorUtils) {
92+
const originalGlobalHandler = global.ErrorUtils.getGlobalHandler()
93+
global.ErrorUtils.setGlobalHandler((error, isFatal) => {
94+
errorHandler(error)
95+
if (originalGlobalHandler) {
96+
originalGlobalHandler(error, isFatal)
97+
}
98+
})
99+
}
100+
101+
// React Native doesn't fully support the standard addEventListener API for unhandledrejection
102+
// This is a workaround using Promise patches
103+
const originalPromiseReject = Promise.reject
104+
Promise.reject = function (reason) {
105+
const result = originalPromiseReject.call(this, reason)
106+
setTimeout(() => {
107+
// If the rejection isn't handled in the next tick, report it
108+
if (!result._handled) {
109+
rejectionHandler({ reason })
110+
}
111+
}, 0)
112+
return result
113+
}
114+
115+
// Patch fetch to catch network errors
116+
const originalFetch = global.fetch
117+
global.fetch = function (...args) {
118+
return originalFetch.apply(this, args).catch((error) => {
119+
networkErrorHandler(error)
120+
throw error // re-throw to preserve original behavior
121+
})
122+
} as typeof fetch
123+
124+
return () => {
125+
// Cleanup if component unmounts (unlikely for root layout)
126+
if (global.ErrorUtils && originalGlobalHandler) {
127+
global.ErrorUtils.setGlobalHandler(originalGlobalHandler)
128+
}
129+
Promise.reject = originalPromiseReject
130+
global.fetch = originalFetch
131+
}
132+
}, [])
133+
44134
useEffect(() => {
45135
if (loaded) {
46136
SplashScreen.hideAsync()

0 commit comments

Comments
 (0)