From 957ac234e64e77b53d759d629b5c0996ec195c3e Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Fri, 3 Oct 2025 14:02:26 -0500 Subject: [PATCH 01/12] Add axios example to E2E app --- e2e/react-native-otel/app/(tabs)/index.tsx | 39 ++++++++++++++++++++++ e2e/react-native-otel/package.json | 1 + yarn.lock | 25 ++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/e2e/react-native-otel/app/(tabs)/index.tsx b/e2e/react-native-otel/app/(tabs)/index.tsx index 38dbfd542..d1476647b 100644 --- a/e2e/react-native-otel/app/(tabs)/index.tsx +++ b/e2e/react-native-otel/app/(tabs)/index.tsx @@ -1,5 +1,6 @@ import { Image, StyleSheet, Alert, Pressable } from 'react-native' import { useState, useEffect } from 'react' +import axios from 'axios' import { HelloWave } from '@/components/HelloWave' import ParallaxScrollView from '@/components/ParallaxScrollView' @@ -463,6 +464,16 @@ export default function HomeScreen() { }, 0) } + const handleAxiosSuccess = async () => { + await axios.get('https://jsonplaceholder.typicode.com/posts/1') + Alert.alert('Axios Request', `Request completed`) + } + + const handleAxios404 = async () => { + await axios.get('https://jsonplaceholder.typicode.com/posts/99999') + Alert.alert('Axios 404', `Request completed`) + } + return ( + + Axios Network Requests + + + + + Axios: Successful Request + + + + + + Axios: 404 Request + + + Error Testing @@ -639,6 +672,12 @@ const styles = StyleSheet.create({ marginVertical: 4, alignItems: 'center', }, + successButton: { + backgroundColor: '#34C759', // Green color for success buttons + }, + warningButton: { + backgroundColor: '#FF9500', // Orange color for warning/404 buttons + }, errorButton: { backgroundColor: '#FF3B30', // Red color for error buttons }, diff --git a/e2e/react-native-otel/package.json b/e2e/react-native-otel/package.json index 215ca82e8..00d42e64a 100644 --- a/e2e/react-native-otel/package.json +++ b/e2e/react-native-otel/package.json @@ -21,6 +21,7 @@ "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", + "axios": "^1.12.2", "expo": "~53.0.13", "expo-blur": "~14.1.5", "expo-constants": "^17.1.6", diff --git a/yarn.lock b/yarn.lock index aee932318..651520de9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19316,6 +19316,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.12.2": + version: 1.12.2 + resolution: "axios@npm:1.12.2" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.4" + proxy-from-env: "npm:^1.1.0" + checksum: 10/886a79770594eaad76493fecf90344b567bd956240609b5dcd09bd0afe8d3e6f1ad6d3257a93a483b6192b409d4b673d9515a34619e3e3ed1b2c0ec2a83b20ba + languageName: node + linkType: hard + "axios@npm:^1.6.8": version: 1.8.2 resolution: "axios@npm:1.8.2" @@ -26805,6 +26816,19 @@ __metadata: languageName: node linkType: hard +"form-data@npm:^4.0.4": + version: 4.0.4 + resolution: "form-data@npm:4.0.4" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" + mime-types: "npm:^2.1.12" + checksum: 10/a4b62e21932f48702bc468cc26fb276d186e6b07b557e3dd7cc455872bdbb82db7db066844a64ad3cf40eaf3a753c830538183570462d3649fdfd705601cbcfb + languageName: node + linkType: hard + "form-data@npm:~2.3.2": version: 2.3.3 resolution: "form-data@npm:2.3.3" @@ -38889,6 +38913,7 @@ __metadata: "@react-navigation/native": "npm:^7.1.6" "@types/react": "npm:~19.0.10" "@types/uuid": "npm:^9.0.7" + axios: "npm:^1.12.2" eslint: "npm:^9.25.0" eslint-config-expo: "npm:~9.2.0" expo: "npm:~53.0.13" From 680869eb4525982308c4beb1377d3e395e1b06b2 Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Fri, 3 Oct 2025 15:29:04 -0500 Subject: [PATCH 02/12] Update unhandled promise rejection --- .../instrumentation/ErrorInstrumentation.ts | 131 +++--------------- 1 file changed, 17 insertions(+), 114 deletions(-) diff --git a/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts b/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts index 7bfa89a57..8433b4f17 100644 --- a/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts +++ b/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts @@ -92,123 +92,26 @@ export class ErrorInstrumentation { } private patchPromiseRejection(): void { - // Store original Promise methods - const originalThen = Promise.prototype.then - const originalCatch = Promise.prototype.catch - - // Store instance reference for use in the patched function - const instance = this - - // Install global unhandled rejection listener - if (global.process && typeof global.process.on === 'function') { - // Node.js style - global.process.on('unhandledRejection', (reason, promise) => { - const event = { - promise, - reason, - preventDefault: () => {}, - } - instance.handleUnhandledRejection(event) - }) - } else if (typeof global.addEventListener === 'function') { - // Browser style - global.addEventListener('unhandledrejection', (event) => { - instance.handleUnhandledRejection(event) - }) + const rejectionTrackingConfig = { + allRejections: true, + onUnhandled: this.handleUnhandledRejection.bind(this), + onHandled: () => {}, } - // Add an error handler for setTimeout/setInterval - const originalSetTimeout = global.setTimeout - - // Wrap setTimeout to catch errors in callbacks but preserve the original types - const wrappedSetTimeout = function ( - callback: Parameters[0], - delay?: Parameters[1], - ...args: any[] - ): ReturnType { - let wrappedCallback = callback - if (typeof callback === 'function') { - wrappedCallback = function (this: any) { - try { - return (callback as Function).apply( - this, - arguments as unknown as any[], - ) - } catch (error) { - instance.handleUnhandledException(error, false) - throw error // Re-throw to maintain original behavior - } - } - } - return originalSetTimeout(wrappedCallback as any, delay, ...args) - } - - // Preserve all properties of setTimeout - Object.defineProperties( - wrappedSetTimeout, - Object.getOwnPropertyDescriptors(originalSetTimeout), - ) - - // Apply the wrapped function - global.setTimeout = wrappedSetTimeout as typeof global.setTimeout - - // Patch Promise.prototype.then to catch unhandled rejections - Promise.prototype.then = function ( - onFulfilled?: - | ((value: any) => TResult1 | PromiseLike) - | null - | undefined, - onRejected?: - | ((reason: any) => TResult2 | PromiseLike) - | null - | undefined, - ): Promise { - let rej = onRejected - // if uninitialized (destroyed), preserve original Promise behavior - if (instance.isInitialized) { - rej = - rej || - ((reason: any): TResult2 | PromiseLike => { - // If no rejection handler is provided, treat as unhandled - setTimeout(() => { - const promiseThis = this - if (promiseThis instanceof Promise) { - const event = { - promise: promiseThis, - reason, - preventDefault: () => {}, - } - instance.handleUnhandledRejection(event) - } - }, 0) - throw reason - }) - } - return originalThen.call(this, onFulfilled, rej) as Promise< - TResult1 | TResult2 - > - } + // @ts-expect-error to allow for checking if HermesInternal exists on `global` since it isn't part of its type + const hermesInternal = global?.HermesInternal - // Also patch Promise.catch to ensure we catch unhandled rejections - Promise.prototype.catch = function ( - onRejected?: - | ((reason: any) => T | PromiseLike) - | null - | undefined, - ): Promise { - return originalCatch.call( - this, - onRejected || - ((reason: any): T | PromiseLike => { - setTimeout(() => { - instance.handleUnhandledRejection({ - promise: this, - reason, - preventDefault: () => {}, - }) - }, 0) - throw reason - }), + // Do the same checking as react-native to make sure we add tracking to the right Promise implementation + // https://github.com/facebook/react-native/blob/v0.77.0/packages/react-native/Libraries/Core/polyfillPromise.js#L25 + if (hermesInternal?.hasPromise?.()) { + hermesInternal?.enablePromiseRejectionTracker?.( + rejectionTrackingConfig, + ) + } else { + // [Unhandled Rejections](https://github.com/then/promise/blob/master/Readme.md#unhandled-rejections) + // [promise/setimmediate/rejection-tracking](https://github.com/then/promise/blob/master/src/rejection-tracking.js) + require('promise/setimmediate/rejection-tracking').enable( + rejectionTrackingConfig, ) } } From 20201251b20fb0e2142d0238881363b8de92489b Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Mon, 6 Oct 2025 08:19:11 -0500 Subject: [PATCH 03/12] Don't skip network error reporting --- .../src/instrumentation/ErrorInstrumentation.ts | 11 ----------- .../src/instrumentation/errorUtils.ts | 16 ---------------- 2 files changed, 27 deletions(-) diff --git a/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts b/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts index 8433b4f17..476a38700 100644 --- a/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts +++ b/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts @@ -3,7 +3,6 @@ import type { ObservabilityClient } from '../client/ObservabilityClient' import { extractReactErrorInfo, formatError, - isNetworkError, parseConsoleArgs, } from './errorUtils' @@ -121,11 +120,6 @@ export class ErrorInstrumentation { const errorObj = error instanceof Error ? error : new Error(String(error)) - // Skip network errors as they're handled by network instrumentation - if (isNetworkError(errorObj)) { - return - } - const reactInfo = extractReactErrorInfo(error) const formattedError = formatError( errorObj, @@ -162,11 +156,6 @@ export class ErrorInstrumentation { const errorObj = reason instanceof Error ? reason : new Error(String(reason)) - // Skip network errors - if (isNetworkError(errorObj)) { - return - } - const formattedError = formatError( errorObj, 'unhandled_rejection', diff --git a/sdk/@launchdarkly/observability-react-native/src/instrumentation/errorUtils.ts b/sdk/@launchdarkly/observability-react-native/src/instrumentation/errorUtils.ts index c9425b189..f7dba6527 100644 --- a/sdk/@launchdarkly/observability-react-native/src/instrumentation/errorUtils.ts +++ b/sdk/@launchdarkly/observability-react-native/src/instrumentation/errorUtils.ts @@ -103,22 +103,6 @@ export function parseConsoleArgs(args: any[]): string { .join(' ') } -export function isNetworkError(error: Error): boolean { - const networkErrorPatterns = [ - /network/i, - /fetch/i, - /XMLHttpRequest/i, - /CORS/i, - /ERR_NETWORK/i, - /ERR_INTERNET_DISCONNECTED/i, - /ERR_NAME_NOT_RESOLVED/i, - ] - - return networkErrorPatterns.some( - (pattern) => pattern.test(error.message) || pattern.test(error.name), - ) -} - export function shouldSampleError(sampleRate: number): boolean { return Math.random() < sampleRate } From 94c5e67bb8434e021e736b156ba9ceadb8e86ba5 Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Mon, 6 Oct 2025 09:10:41 -0500 Subject: [PATCH 04/12] Improve formatting for axios errors --- .../observability-react-native/package.json | 1 + .../src/__mocks__/react-native.ts | 8 + .../instrumentation/ErrorInstrumentation.ts | 167 ++++++- .../__tests__/errorUtils.test.ts | 470 ++++++++++++++++++ .../src/instrumentation/errorUtils.ts | 136 +++++ yarn.lock | 1 + 6 files changed, 763 insertions(+), 20 deletions(-) create mode 100644 sdk/@launchdarkly/observability-react-native/src/instrumentation/__tests__/errorUtils.test.ts diff --git a/sdk/@launchdarkly/observability-react-native/package.json b/sdk/@launchdarkly/observability-react-native/package.json index a11fe03df..d75025285 100644 --- a/sdk/@launchdarkly/observability-react-native/package.json +++ b/sdk/@launchdarkly/observability-react-native/package.json @@ -52,6 +52,7 @@ "devDependencies": { "@launchdarkly/observability-shared": "workspace:*", "@launchdarkly/react-native-client-sdk": "^10.0.0", + "axios": "^1.12.2", "react-native": "^0.79.0", "typedoc": "^0.28.4", "typescript": "^5.0.4", diff --git a/sdk/@launchdarkly/observability-react-native/src/__mocks__/react-native.ts b/sdk/@launchdarkly/observability-react-native/src/__mocks__/react-native.ts index 7923745ea..f46d2151d 100644 --- a/sdk/@launchdarkly/observability-react-native/src/__mocks__/react-native.ts +++ b/sdk/@launchdarkly/observability-react-native/src/__mocks__/react-native.ts @@ -1,3 +1,5 @@ +import { vi } from 'vitest' + export const Platform = { OS: 'ios' as const, select: (config: any) => config.ios || config.default, @@ -28,3 +30,9 @@ export default { DeviceEventEmitter, NativeEventEmitter, } + +// Mock global ErrorUtils (React Native global) +;(globalThis as any).ErrorUtils = { + getGlobalHandler: vi.fn(() => undefined), + setGlobalHandler: vi.fn(), +} diff --git a/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts b/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts index 476a38700..95231d27a 100644 --- a/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts +++ b/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts @@ -2,6 +2,7 @@ import { Attributes } from '@opentelemetry/api' import type { ObservabilityClient } from '../client/ObservabilityClient' import { extractReactErrorInfo, + extractRejectionDetails, formatError, parseConsoleArgs, } from './errorUtils' @@ -15,6 +16,8 @@ export class ErrorInstrumentation { unhandledRejection?: (event: any) => void } = {} private isInitialized = false + private originalPromiseThen?: typeof Promise.prototype.then + private unhandledRejections: Set> = new Set() constructor(client: ObservabilityClient) { this.client = client @@ -44,6 +47,7 @@ export class ErrorInstrumentation { try { this.restoreUnhandledExceptionHandler() + this.restorePromiseRejectionHandler() this.restoreConsoleHandlers() this.isInitialized = false this.client._log('ErrorInstrumentation destroyed') @@ -91,28 +95,138 @@ export class ErrorInstrumentation { } private patchPromiseRejection(): void { - const rejectionTrackingConfig = { - allRejections: true, - onUnhandled: this.handleUnhandledRejection.bind(this), - onHandled: () => {}, + // Custom promise rejection tracking implementation + // We patch Promise.prototype.then to track rejections and detect when they're handled + + // Store the original Promise.prototype.then + this.originalPromiseThen = Promise.prototype.then + + const self = this + const originalThen = this.originalPromiseThen + + // Patch Promise.prototype.then to track rejection handlers + Promise.prototype.then = function ( + this: Promise, + onFulfilled?: + | ((value: any) => TResult1 | PromiseLike) + | null + | undefined, + onRejected?: + | ((reason: any) => TResult2 | PromiseLike) + | null + | undefined, + ): Promise { + const thisPromise = this + + // If this promise has a rejection handler, remove it from unhandled set + if (onRejected) { + self.unhandledRejections.delete(thisPromise) + } + + // Call the original then with a wrapped onRejected to track handling + const wrappedOnRejected = onRejected + ? function (reason: any) { + // This rejection is being handled + self.unhandledRejections.delete(thisPromise) + return onRejected(reason) + } + : undefined + + // Call original then + const resultPromise = originalThen.call( + thisPromise, + onFulfilled, + wrappedOnRejected, + ) as Promise + + // If the result promise rejects, we need to track it too + originalThen.call(resultPromise, undefined, function (reason: any) { + // Mark this promise as potentially unhandled + self.unhandledRejections.add(resultPromise) + + // Check after a microtask if it's still unhandled + setTimeout(() => { + if (self.unhandledRejections.has(resultPromise)) { + self.unhandledRejections.delete(resultPromise) + self.handleUnhandledRejection({ reason }) + } + }, 0) + + // Re-throw to preserve rejection + throw reason + }) + + return resultPromise + } as any // Type assertion needed due to Promise patching + + // Also need to track Promise.prototype.catch + const originalCatch = Promise.prototype.catch + Promise.prototype.catch = function (onRejected) { + // Remove from unhandled set when catch is added + self.unhandledRejections.delete(this) + return originalCatch.call(this, onRejected) } - // @ts-expect-error to allow for checking if HermesInternal exists on `global` since it isn't part of its type - const hermesInternal = global?.HermesInternal + // Track rejections from Promise.reject + const originalReject = Promise.reject + Promise.reject = function (reason?: any): Promise { + const promise = originalReject.call(this, reason) as Promise - // Do the same checking as react-native to make sure we add tracking to the right Promise implementation - // https://github.com/facebook/react-native/blob/v0.77.0/packages/react-native/Libraries/Core/polyfillPromise.js#L25 - if (hermesInternal?.hasPromise?.()) { - hermesInternal?.enablePromiseRejectionTracker?.( - rejectionTrackingConfig, - ) - } else { - // [Unhandled Rejections](https://github.com/then/promise/blob/master/Readme.md#unhandled-rejections) - // [promise/setimmediate/rejection-tracking](https://github.com/then/promise/blob/master/src/rejection-tracking.js) - require('promise/setimmediate/rejection-tracking').enable( - rejectionTrackingConfig, - ) + // Mark as potentially unhandled + self.unhandledRejections.add(promise) + + // Check after a microtask if it's still unhandled + setTimeout(() => { + if (self.unhandledRejections.has(promise)) { + self.unhandledRejections.delete(promise) + self.handleUnhandledRejection({ reason }) + } + }, 0) + + return promise + } + + // Track rejections from new Promise((resolve, reject) => reject(...)) + const OriginalPromise = Promise + const PromiseConstructor = function ( + this: any, + executor: ( + resolve: (value?: any) => void, + reject: (reason?: any) => void, + ) => void, + ) { + const promise = new OriginalPromise((resolve, reject) => { + const wrappedReject = (reason?: any) => { + // Mark as potentially unhandled + self.unhandledRejections.add(promise) + + // Check after a microtask if it's still unhandled + setTimeout(() => { + if (self.unhandledRejections.has(promise)) { + self.unhandledRejections.delete(promise) + self.handleUnhandledRejection({ reason }) + } + }, 0) + + reject(reason) + } + + try { + executor(resolve, wrappedReject) + } catch (error) { + wrappedReject(error) + } + }) + + return promise } + + // Copy static methods + Object.setPrototypeOf(PromiseConstructor, OriginalPromise) + PromiseConstructor.prototype = OriginalPromise.prototype + + // Replace global Promise (with type assertion to handle constructor replacement) + ;(global as any).Promise = PromiseConstructor } private handleUnhandledException(error: any, isFatal: boolean): void { @@ -153,8 +267,10 @@ export class ErrorInstrumentation { private handleUnhandledRejection(event: any): void { try { const reason = event.reason || event - const errorObj = - reason instanceof Error ? reason : new Error(String(reason)) + + console.log('::: event:', event) + const { error: errorObj, attributes: rejectionAttributes } = + extractRejectionDetails(reason) const formattedError = formatError( errorObj, @@ -165,6 +281,7 @@ export class ErrorInstrumentation { const attributes: Attributes = { ...formattedError.attributes, + ...rejectionAttributes, // Add extracted rejection details 'error.unhandled': true, 'error.caught_by': 'unhandledrejection', 'promise.handled': false, @@ -227,6 +344,16 @@ export class ErrorInstrumentation { } } + private restorePromiseRejectionHandler(): void { + // Restore original Promise.prototype.then if we patched it + if (this.originalPromiseThen) { + Promise.prototype.then = this.originalPromiseThen + } + + // Clear any tracked unhandled rejections + this.unhandledRejections.clear() + } + private restoreConsoleHandlers(): void { if (this.originalHandlers.consoleError) { console.error = this.originalHandlers.consoleError diff --git a/sdk/@launchdarkly/observability-react-native/src/instrumentation/__tests__/errorUtils.test.ts b/sdk/@launchdarkly/observability-react-native/src/instrumentation/__tests__/errorUtils.test.ts new file mode 100644 index 000000000..fab93bf06 --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/instrumentation/__tests__/errorUtils.test.ts @@ -0,0 +1,470 @@ +import { describe, it, expect } from 'vitest' +import { extractRejectionDetails } from '../errorUtils' +import type { + AxiosError, + InternalAxiosRequestConfig, + AxiosResponse, +} from 'axios' +import type { Attributes } from '@opentelemetry/api' + +describe('extractRejectionDetails', () => { + describe('Error instances', () => { + it('should handle a standard Error object', () => { + const error = new Error('Something went wrong') + error.name = 'TestError' + error.stack = 'Error: Something went wrong\n at test.js:1:1' + + const result = extractRejectionDetails(error) + + expect(result.error).toBe(error) + expect(result.attributes).toEqual({}) + }) + + it('should handle TypeError', () => { + const error = new TypeError('Invalid type') + + const result = extractRejectionDetails(error) + + expect(result.error).toBe(error) + expect(result.error.name).toBe('TypeError') + expect(result.attributes).toEqual({}) + }) + }) + + describe('Axios errors', () => { + it('should extract axios error with response data', () => { + const mockConfig = { + method: 'get', + url: 'https://api.example.com/users/123', + } as InternalAxiosRequestConfig + + const mockResponse: Partial = { + status: 404, + statusText: 'Not Found', + data: { message: 'Resource not found' }, + headers: {}, + config: mockConfig, + } + + const axiosError = new Error( + 'Request failed with status code 404', + ) as AxiosError + axiosError.name = 'AxiosError' + ;(axiosError as any).isAxiosError = true + axiosError.response = mockResponse as AxiosResponse + axiosError.config = mockConfig + axiosError.code = 'ERR_BAD_REQUEST' + + const result = extractRejectionDetails(axiosError) + + expect(result.error).toBe(axiosError) + expect(result.attributes).toEqual({ + 'http.is_axios_error': true, + 'http.status_code': 404, + 'http.status_text': 'Not Found', + 'http.response_data': '{"message":"Resource not found"}', + 'http.method': 'GET', + 'http.url': 'https://api.example.com/users/123', + 'http.error_code': 'ERR_BAD_REQUEST', + }) + }) + + it('should handle axios error with string response data', () => { + const mockConfig = { + method: 'post', + url: 'https://api.example.com/data', + } as InternalAxiosRequestConfig + + const mockResponse: Partial = { + status: 500, + statusText: 'Internal Server Error', + data: 'Server error occurred', + headers: {}, + config: mockConfig, + } + + const axiosError = new Error('Request failed') as AxiosError + ;(axiosError as any).isAxiosError = true + axiosError.response = mockResponse as AxiosResponse + axiosError.config = mockConfig + + const result = extractRejectionDetails(axiosError) + + expect(result.attributes).toMatchObject({ + 'http.is_axios_error': true, + 'http.status_code': 500, + 'http.response_data': 'Server error occurred', + }) + }) + + it('should handle axios error without response', () => { + const mockConfig = { + method: 'delete', + url: 'https://api.example.com/resource', + } as InternalAxiosRequestConfig + + const axiosError = new Error('Network Error') as AxiosError + ;(axiosError as any).isAxiosError = true + axiosError.config = mockConfig + axiosError.code = 'ERR_NETWORK' + + const result = extractRejectionDetails(axiosError) + + expect(result.attributes).toEqual({ + 'http.is_axios_error': true, + 'http.method': 'DELETE', + 'http.url': 'https://api.example.com/resource', + 'http.error_code': 'ERR_NETWORK', + }) + }) + + it('should handle axios error with non-stringifiable response data', () => { + const circularData: any = { prop: 'value' } + circularData.circular = circularData + + const mockConfig = {} as InternalAxiosRequestConfig + + const mockResponse: Partial = { + status: 400, + statusText: 'Bad Request', + data: circularData, + headers: {}, + config: mockConfig, + } + + const axiosError = new Error('Bad request') as AxiosError + ;(axiosError as any).isAxiosError = true + axiosError.response = mockResponse as AxiosResponse + + const result = extractRejectionDetails(axiosError) + + expect(result.attributes['http.is_axios_error']).toBe(true) + expect(result.attributes['http.response_data']).toBeUndefined() + }) + + it('should handle axios timeout error', () => { + const mockConfig = { + method: 'get', + url: 'https://api.example.com/slow', + } as InternalAxiosRequestConfig + + const axiosError = new Error( + 'timeout of 5000ms exceeded', + ) as AxiosError + ;(axiosError as any).isAxiosError = true + axiosError.config = mockConfig + axiosError.code = 'ECONNABORTED' + + const result = extractRejectionDetails(axiosError) + + expect(result.attributes).toEqual({ + 'http.is_axios_error': true, + 'http.method': 'GET', + 'http.url': 'https://api.example.com/slow', + 'http.error_code': 'ECONNABORTED', + }) + }) + }) + + describe('Fetch/Network errors', () => { + it('should detect fetch errors', () => { + const fetchError = new TypeError('Failed to fetch') + + const result = extractRejectionDetails(fetchError) + + expect(result.error).toBe(fetchError) + expect(result.attributes).toEqual({ + 'http.is_fetch_error': true, + }) + }) + + it('should detect network errors', () => { + const networkError = new TypeError('Network request failed') + + const result = extractRejectionDetails(networkError) + + expect(result.error).toBe(networkError) + expect(result.attributes).toEqual({ + 'http.is_fetch_error': true, + }) + }) + + it('should not mark non-network TypeErrors as fetch errors', () => { + const typeError = new TypeError('undefined is not a function') + + const result = extractRejectionDetails(typeError) + + expect(result.attributes).toEqual({}) + }) + }) + + describe('Plain objects', () => { + it('should handle object with message property', () => { + const rejection = { + message: 'Custom error message', + code: 'ERR_CUSTOM', + details: 'Some additional details', + } + + const result = extractRejectionDetails(rejection) + + expect(result.error).toBeInstanceOf(Error) + expect(result.error.message).toBe('Custom error message') + expect(result.error.name).toBe('UnhandledRejection') + expect(result.attributes).toEqual({ + 'rejection.message': 'Custom error message', + 'rejection.code': 'ERR_CUSTOM', + 'rejection.details': 'Some additional details', + }) + }) + + it('should handle object with error property', () => { + const rejection = { + error: 'Something went wrong', + status: 500, + } + + const result = extractRejectionDetails(rejection) + + expect(result.error.message).toBe('Something went wrong') + expect(result.attributes).toEqual({ + 'rejection.error': 'Something went wrong', + 'rejection.status': 500, + }) + }) + + it('should handle object with description property', () => { + const rejection = { + description: 'Error description', + timestamp: 1234567890, + } + + const result = extractRejectionDetails(rejection) + + expect(result.error.message).toBe('Error description') + expect(result.attributes).toEqual({ + 'rejection.description': 'Error description', + 'rejection.timestamp': 1234567890, + }) + }) + + it('should handle object with name property', () => { + const rejection = { + name: 'CustomError', + message: 'Custom message', + } + + const result = extractRejectionDetails(rejection) + + expect(result.error.name).toBe('CustomError') + expect(result.error.message).toBe('Custom message') + }) + + it('should handle object with stack property', () => { + const customStack = 'Error: Custom\n at test.js:1:1' + const rejection = { + message: 'Error with stack', + stack: customStack, + } + + const result = extractRejectionDetails(rejection) + + expect(result.error.stack).toBe(customStack) + }) + + it('should handle object with nested object properties', () => { + const rejection = { + message: 'Error with metadata', + metadata: { userId: '123', requestId: 'abc' }, + count: 5, + } + + const result = extractRejectionDetails(rejection) + + expect(result.attributes).toEqual({ + 'rejection.message': 'Error with metadata', + 'rejection.metadata': '{"userId":"123","requestId":"abc"}', + 'rejection.count': 5, + }) + }) + + it('should skip function properties', () => { + const rejection = { + message: 'Error with function', + handler: () => {}, + code: 'ERR_TEST', + } + + const result = extractRejectionDetails(rejection) + + expect(result.attributes).toEqual({ + 'rejection.message': 'Error with function', + 'rejection.code': 'ERR_TEST', + }) + }) + + it('should handle object with non-stringifiable nested object', () => { + const circularObj: any = { prop: 'value' } + circularObj.circular = circularObj + + const rejection = { + message: 'Error with circular ref', + data: circularObj, + } + + const result = extractRejectionDetails(rejection) + + expect(result.attributes['rejection.message']).toBe( + 'Error with circular ref', + ) + expect(result.attributes['rejection.data']).toBeUndefined() + }) + + it('should use default message for object without message-like properties', () => { + const rejection = { + code: 'ERR_UNKNOWN', + timestamp: 123456, + } + + const result = extractRejectionDetails(rejection) + + expect(result.error.message).toBe('Promise rejected with object') + }) + + it('should handle empty object', () => { + const rejection = {} + + const result = extractRejectionDetails(rejection) + + expect(result.error.message).toBe('Promise rejected with object') + expect(result.error.name).toBe('UnhandledRejection') + expect(result.attributes).toEqual({}) + }) + }) + + describe('Primitives', () => { + it('should handle string rejection', () => { + const rejection = 'Error string' + + const result = extractRejectionDetails(rejection) + + expect(result.error).toBeInstanceOf(Error) + expect(result.error.message).toBe( + 'Promise rejected with string: Error string', + ) + expect(result.error.name).toBe('UnhandledRejection') + expect(result.attributes).toEqual({ + 'rejection.type': 'string', + 'rejection.value': 'Error string', + }) + }) + + it('should handle number rejection', () => { + const rejection = 42 + + const result = extractRejectionDetails(rejection) + + expect(result.error.message).toBe( + 'Promise rejected with number: 42', + ) + expect(result.attributes).toEqual({ + 'rejection.type': 'number', + 'rejection.value': '42', + }) + }) + + it('should handle boolean rejection (true)', () => { + const rejection = true + + const result = extractRejectionDetails(rejection) + + expect(result.error.message).toBe( + 'Promise rejected with boolean: true', + ) + expect(result.attributes).toEqual({ + 'rejection.type': 'boolean', + 'rejection.value': 'true', + }) + }) + + it('should handle boolean rejection (false)', () => { + const rejection = false + + const result = extractRejectionDetails(rejection) + + expect(result.error.message).toBe( + 'Promise rejected with boolean: false', + ) + expect(result.attributes).toEqual({ + 'rejection.type': 'boolean', + 'rejection.value': 'false', + }) + }) + + it('should handle null rejection', () => { + const rejection = null + + const result = extractRejectionDetails(rejection) + + expect(result.error.message).toBe('Promise rejected with null') + expect(result.attributes).toEqual({ + 'rejection.type': 'object', + 'rejection.value': 'null', + }) + }) + + it('should handle undefined rejection', () => { + const rejection = undefined + + const result = extractRejectionDetails(rejection) + + expect(result.error.message).toBe('Promise rejected with undefined') + expect(result.attributes).toEqual({ + 'rejection.type': 'undefined', + 'rejection.value': 'undefined', + }) + }) + + it('should handle zero as rejection', () => { + const rejection = 0 + + const result = extractRejectionDetails(rejection) + + expect(result.error.message).toBe('Promise rejected with number: 0') + expect(result.attributes).toEqual({ + 'rejection.type': 'number', + 'rejection.value': '0', + }) + }) + + it('should handle empty string rejection', () => { + const rejection = '' + + const result = extractRejectionDetails(rejection) + + expect(result.error.message).toBe('Promise rejected with string: ') + expect(result.attributes).toEqual({ + 'rejection.type': 'string', + 'rejection.value': '', + }) + }) + }) + + describe('Edge cases', () => { + it('should return correct type for all attributes', () => { + const rejection = { + message: 'Test', + code: 123, + active: true, + } + + const result = extractRejectionDetails(rejection) + + // Verify attributes conform to Attributes type (string | number | boolean values) + const attributes: Attributes = result.attributes + expect(typeof attributes['rejection.message']).toBe('string') + expect(typeof attributes['rejection.code']).toBe('number') + expect(typeof attributes['rejection.active']).toBe('boolean') + }) + }) +}) diff --git a/sdk/@launchdarkly/observability-react-native/src/instrumentation/errorUtils.ts b/sdk/@launchdarkly/observability-react-native/src/instrumentation/errorUtils.ts index f7dba6527..de626fb38 100644 --- a/sdk/@launchdarkly/observability-react-native/src/instrumentation/errorUtils.ts +++ b/sdk/@launchdarkly/observability-react-native/src/instrumentation/errorUtils.ts @@ -6,6 +6,7 @@ import { ErrorSource, FormattedError, } from './errorTypes' +import type { AxiosError } from 'axios' export function formatError( error: Error | any, @@ -103,6 +104,141 @@ export function parseConsoleArgs(args: any[]): string { .join(' ') } +/** + * Extract detailed information from an unhandled promise rejection reason. + * This handles various types of rejection reasons including: + * - Error objects + * - Axios errors + * - Fetch errors + * - Primitives (numbers, strings, etc.) + * - Plain objects + */ +export function extractRejectionDetails(reason: any): { + error: Error + attributes: Attributes +} { + const attributes: Attributes = {} + + if (reason instanceof Error) { + if ('isAxiosError' in reason) { + attributes['http.is_axios_error'] = true + + const axiosError = reason as AxiosError + if (axiosError.response) { + attributes['http.status_code'] = axiosError.response.status + attributes['http.status_text'] = axiosError.response.statusText + if (axiosError.response.data) { + try { + attributes['http.response_data'] = + typeof axiosError.response.data === 'string' + ? axiosError.response.data + : JSON.stringify(axiosError.response.data) + } catch { + // Ignore if response data can't be stringified + } + } + } + + if (axiosError.config) { + attributes['http.method'] = + axiosError.config.method?.toUpperCase() + attributes['http.url'] = axiosError.config.url + } + + if (axiosError.code) { + attributes['http.error_code'] = axiosError.code + } + } + + // Check for fetch/network error structure + if ( + reason.name === 'TypeError' && + /fetch|network/i.test(reason.message) + ) { + attributes['http.is_fetch_error'] = true + } + + return { error: reason, attributes } + } + + // Handle objects with error-like properties + if (reason && typeof reason === 'object') { + let message = 'Promise rejected with object' + + // Try to extract a meaningful message + if (reason.message) { + message = String(reason.message) + } else if (reason.error) { + message = String(reason.error) + } else if (reason.description) { + message = String(reason.description) + } + + // Add all enumerable properties as attributes + try { + Object.keys(reason).forEach((key) => { + const value = reason[key] + // Skip functions and complex objects + if (typeof value !== 'function' && typeof value !== 'object') { + attributes[`rejection.${key}`] = value + } else if (value && typeof value === 'object') { + try { + attributes[`rejection.${key}`] = JSON.stringify(value) + } catch { + // Skip if can't stringify + } + } + }) + } catch { + // Ignore errors while extracting properties + } + + const error = new Error(message) + error.name = reason.name || 'UnhandledRejection' + + // Try to preserve stack if available + if (reason.stack) { + error.stack = reason.stack + } + + return { error, attributes } + } + + // Handle primitives (numbers, strings, booleans, etc.) + const primitiveType = typeof reason + let message: string + + if (reason === null || reason === undefined) { + message = `Promise rejected with ${reason}` + } else { + message = `Promise rejected with ${primitiveType}: ${reason}` + } + + attributes['rejection.type'] = primitiveType + attributes['rejection.value'] = String(reason) + + const error = new Error(message) + error.name = 'UnhandledRejection' + + return { error, attributes } +} + +export function isNetworkError(error: Error): boolean { + const networkErrorPatterns = [ + /network/i, + /fetch/i, + /XMLHttpRequest/i, + /CORS/i, + /ERR_NETWORK/i, + /ERR_INTERNET_DISCONNECTED/i, + /ERR_NAME_NOT_RESOLVED/i, + ] + + return networkErrorPatterns.some( + (pattern) => pattern.test(error.message) || pattern.test(error.name), + ) +} + export function shouldSampleError(sampleRate: number): boolean { return Math.random() < sampleRate } diff --git a/yarn.lock b/yarn.lock index 651520de9..0ab46cce4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8574,6 +8574,7 @@ __metadata: "@opentelemetry/sdk-trace-base": "npm:2.0.1" "@opentelemetry/sdk-trace-web": "npm:^2.0.1" "@opentelemetry/semantic-conventions": "npm:^1.35.0" + axios: "npm:^1.12.2" react-native: "npm:^0.79.0" typedoc: "npm:^0.28.4" typescript: "npm:^5.0.4" From 54eaab6ca5b008d8b73fddbbb4b8a738063454b7 Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Mon, 6 Oct 2025 09:31:54 -0500 Subject: [PATCH 05/12] Add tests and fix logic --- e2e/react-native-otel/app/(tabs)/index.tsx | 8 +- .../instrumentation/ErrorInstrumentation.ts | 38 ++-- .../__tests__/ErrorInstrumentation.test.ts | 208 +++++++++++++++++- 3 files changed, 233 insertions(+), 21 deletions(-) diff --git a/e2e/react-native-otel/app/(tabs)/index.tsx b/e2e/react-native-otel/app/(tabs)/index.tsx index d1476647b..94ba95046 100644 --- a/e2e/react-native-otel/app/(tabs)/index.tsx +++ b/e2e/react-native-otel/app/(tabs)/index.tsx @@ -470,8 +470,12 @@ export default function HomeScreen() { } const handleAxios404 = async () => { - await axios.get('https://jsonplaceholder.typicode.com/posts/99999') - Alert.alert('Axios 404', `Request completed`) + try { + await axios.get('https://jsonplaceholder.typicode.com/posts/99999') + } catch (error) { + Alert.alert('Axios 404', `Request completed`) + throw error + } } return ( diff --git a/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts b/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts index 95231d27a..fc608a6c7 100644 --- a/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts +++ b/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts @@ -95,16 +95,11 @@ export class ErrorInstrumentation { } private patchPromiseRejection(): void { - // Custom promise rejection tracking implementation - // We patch Promise.prototype.then to track rejections and detect when they're handled - - // Store the original Promise.prototype.then this.originalPromiseThen = Promise.prototype.then const self = this const originalThen = this.originalPromiseThen - // Patch Promise.prototype.then to track rejection handlers Promise.prototype.then = function ( this: Promise, onFulfilled?: @@ -195,19 +190,14 @@ export class ErrorInstrumentation { reject: (reason?: any) => void, ) => void, ) { + let rejectedReason: any = undefined + let wasRejected = false + const promise = new OriginalPromise((resolve, reject) => { const wrappedReject = (reason?: any) => { - // Mark as potentially unhandled - self.unhandledRejections.add(promise) - - // Check after a microtask if it's still unhandled - setTimeout(() => { - if (self.unhandledRejections.has(promise)) { - self.unhandledRejections.delete(promise) - self.handleUnhandledRejection({ reason }) - } - }, 0) - + // Store rejection info for later processing + wasRejected = true + rejectedReason = reason reject(reason) } @@ -218,6 +208,21 @@ export class ErrorInstrumentation { } }) + // Now that promise is initialized, we can safely track it + if (wasRejected) { + self.unhandledRejections.add(promise) + + // Check after a microtask if it's still unhandled + setTimeout(() => { + if (self.unhandledRejections.has(promise)) { + self.unhandledRejections.delete(promise) + self.handleUnhandledRejection({ + reason: rejectedReason, + }) + } + }, 0) + } + return promise } @@ -268,7 +273,6 @@ export class ErrorInstrumentation { try { const reason = event.reason || event - console.log('::: event:', event) const { error: errorObj, attributes: rejectionAttributes } = extractRejectionDetails(reason) diff --git a/sdk/@launchdarkly/observability-react-native/src/instrumentation/__tests__/ErrorInstrumentation.test.ts b/sdk/@launchdarkly/observability-react-native/src/instrumentation/__tests__/ErrorInstrumentation.test.ts index bda090dbb..16b031c51 100644 --- a/sdk/@launchdarkly/observability-react-native/src/instrumentation/__tests__/ErrorInstrumentation.test.ts +++ b/sdk/@launchdarkly/observability-react-native/src/instrumentation/__tests__/ErrorInstrumentation.test.ts @@ -34,6 +34,10 @@ const mockErrorUtils = { const originalConsoleError = console.error const originalConsoleWarn = console.warn +process.on('unhandledRejection', (_reason: any) => { + // Silently ignore - these are expected in our tests +}) + describe('ErrorInstrumentation', () => { let mockClient: Partial let errorInstrumentation: ErrorInstrumentation @@ -108,6 +112,204 @@ describe('ErrorInstrumentation', () => { }) }) + describe('unhandled promise rejection handling', () => { + it('should capture unhandled promise rejections with Error objects', async () => { + errorInstrumentation = new ErrorInstrumentation( + mockClient as ObservabilityClient, + ) + errorInstrumentation.initialize() + + const testError = new Error('Test promise rejection') + + // Create an unhandled promise rejection + Promise.reject(testError) + + // Wait for the setTimeout in the instrumentation to fire + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(mockClient.consumeCustomError).toHaveBeenCalledWith( + testError, + expect.objectContaining({ + 'error.unhandled': true, + 'error.caught_by': 'unhandledrejection', + 'promise.handled': false, + }), + ) + }) + + it('should capture unhandled promise rejections with primitives', async () => { + errorInstrumentation = new ErrorInstrumentation( + mockClient as ObservabilityClient, + ) + errorInstrumentation.initialize() + + // Create an unhandled promise rejection with a string + Promise.reject('String rejection reason') + + // Wait for the setTimeout in the instrumentation to fire + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(mockClient.consumeCustomError).toHaveBeenCalledWith( + expect.objectContaining({ + message: + 'Promise rejected with string: String rejection reason', + name: 'UnhandledRejection', + }), + expect.objectContaining({ + 'error.unhandled': true, + 'error.caught_by': 'unhandledrejection', + 'rejection.type': 'string', + 'rejection.value': 'String rejection reason', + }), + ) + }) + + it('should capture unhandled promise rejections with objects', async () => { + errorInstrumentation = new ErrorInstrumentation( + mockClient as ObservabilityClient, + ) + errorInstrumentation.initialize() + + // Create an unhandled promise rejection with an object + Promise.reject({ + message: 'Custom error object', + code: 'ERR_CUSTOM', + }) + + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(mockClient.consumeCustomError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Custom error object', + name: 'UnhandledRejection', + }), + expect.objectContaining({ + 'error.unhandled': true, + 'error.caught_by': 'unhandledrejection', + 'rejection.message': 'Custom error object', + 'rejection.code': 'ERR_CUSTOM', + }), + ) + }) + + it('should capture unhandled promise rejections from Promise constructor', async () => { + errorInstrumentation = new ErrorInstrumentation( + mockClient as ObservabilityClient, + ) + errorInstrumentation.initialize() + + const testError = new Error('Constructor rejection') + + // Create an unhandled promise rejection using constructor + new Promise((_resolve, reject) => { + reject(testError) + }) + + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(mockClient.consumeCustomError).toHaveBeenCalledWith( + testError, + expect.objectContaining({ + 'error.unhandled': true, + 'error.caught_by': 'unhandledrejection', + }), + ) + }) + + it('should NOT capture promise rejections that are handled with catch', async () => { + errorInstrumentation = new ErrorInstrumentation( + mockClient as ObservabilityClient, + ) + errorInstrumentation.initialize() + + const testError = new Error('Handled rejection') + + // Create a promise rejection that is handled + Promise.reject(testError).catch(() => { + // Handle the error + }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockClient.consumeCustomError).not.toHaveBeenCalled() + }) + + it('should NOT capture promise rejections that are handled with then', async () => { + errorInstrumentation = new ErrorInstrumentation( + mockClient as ObservabilityClient, + ) + errorInstrumentation.initialize() + + const testError = new Error('Handled rejection') + + // Create a promise rejection that is handled with then's second argument + Promise.reject(testError).then(null, () => { + // Handle the error + }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(mockClient.consumeCustomError).not.toHaveBeenCalled() + }) + + it('should capture axios-like errors with HTTP details', async () => { + errorInstrumentation = new ErrorInstrumentation( + mockClient as ObservabilityClient, + ) + errorInstrumentation.initialize() + + const axiosError = new Error('Request failed with status code 404') + ;(axiosError as any).isAxiosError = true + ;(axiosError as any).response = { + status: 404, + statusText: 'Not Found', + data: { message: 'Resource not found' }, + } + ;(axiosError as any).config = { + method: 'get', + url: 'https://api.example.com/users/123', + } + ;(axiosError as any).code = 'ERR_BAD_REQUEST' + + Promise.reject(axiosError) + + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(mockClient.consumeCustomError).toHaveBeenCalledWith( + axiosError, + expect.objectContaining({ + 'error.unhandled': true, + 'error.caught_by': 'unhandledrejection', + 'http.is_axios_error': true, + 'http.status_code': 404, + 'http.status_text': 'Not Found', + 'http.method': 'GET', + 'http.url': 'https://api.example.com/users/123', + 'http.error_code': 'ERR_BAD_REQUEST', + }), + ) + }) + + it('should capture rejections with null or undefined', async () => { + errorInstrumentation = new ErrorInstrumentation( + mockClient as ObservabilityClient, + ) + errorInstrumentation.initialize() + + const rejectedPromise = Promise.reject(undefined) + + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(mockClient.consumeCustomError).toHaveBeenCalled() + const call = (mockClient.consumeCustomError as any).mock.calls[0] + expect(call[0].message).toContain('Promise rejected') + expect(call[1]).toMatchObject({ + 'error.unhandled': true, + 'error.caught_by': 'unhandledrejection', + }) + }) + }) + describe('console error handling', () => { it('should capture console.error calls', () => { errorInstrumentation = new ErrorInstrumentation( @@ -144,10 +346,12 @@ describe('ErrorInstrumentation', () => { }) }) - describe('cleanup', () => { + describe('destroy', () => { it('should restore original handlers on destroy', () => { const originalHandler = vi.fn() - mockErrorUtils.getGlobalHandler.mockReturnValue(originalHandler) + mockErrorUtils.getGlobalHandler.mockReturnValue( + originalHandler as any, + ) errorInstrumentation = new ErrorInstrumentation( mockClient as ObservabilityClient, From 5c9e7cdc662cc9574762c4f57b0d7d5b53fb4b5a Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Tue, 7 Oct 2025 09:48:21 -0500 Subject: [PATCH 06/12] Add 500 example --- e2e/react-native-otel/app/(tabs)/index.tsx | 18 ++++++++++++++++++ .../__tests__/ErrorInstrumentation.test.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/e2e/react-native-otel/app/(tabs)/index.tsx b/e2e/react-native-otel/app/(tabs)/index.tsx index 94ba95046..6ca68a06b 100644 --- a/e2e/react-native-otel/app/(tabs)/index.tsx +++ b/e2e/react-native-otel/app/(tabs)/index.tsx @@ -478,6 +478,15 @@ export default function HomeScreen() { } } + const handleAxios500 = async () => { + try { + await axios.get('https://httpstatuses.maor.io/500') + } catch (error) { + Alert.alert('Axios 500', `Request completed`) + throw error + } + } + return ( + + + Axios: 500 Request + + + Error Testing diff --git a/sdk/@launchdarkly/observability-react-native/src/instrumentation/__tests__/ErrorInstrumentation.test.ts b/sdk/@launchdarkly/observability-react-native/src/instrumentation/__tests__/ErrorInstrumentation.test.ts index 16b031c51..c9abe15b7 100644 --- a/sdk/@launchdarkly/observability-react-native/src/instrumentation/__tests__/ErrorInstrumentation.test.ts +++ b/sdk/@launchdarkly/observability-react-native/src/instrumentation/__tests__/ErrorInstrumentation.test.ts @@ -296,7 +296,7 @@ describe('ErrorInstrumentation', () => { ) errorInstrumentation.initialize() - const rejectedPromise = Promise.reject(undefined) + Promise.reject(undefined) await new Promise((resolve) => setTimeout(resolve, 20)) From d16b107745925d0606c70a39511f5c28584b674e Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Tue, 7 Oct 2025 15:12:29 -0500 Subject: [PATCH 07/12] Use Hermes.enablePromiseRejectionTracker --- .../instrumentation/ErrorInstrumentation.ts | 217 ++++-------------- .../__tests__/ErrorInstrumentation.test.ts | 106 ++++----- .../src/instrumentation/errorUtils.ts | 16 -- 3 files changed, 81 insertions(+), 258 deletions(-) diff --git a/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts b/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts index fc608a6c7..da913577d 100644 --- a/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts +++ b/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts @@ -7,17 +7,24 @@ import { parseConsoleArgs, } from './errorUtils' +// Type for HermesInternal +interface HermesInternal { + enablePromiseRejectionTracker: (options: { + allRejections: boolean + onUnhandled: (id: number, error: any) => void + onHandled: (id: number) => void + }) => void +} + +declare const HermesInternal: HermesInternal | undefined + export class ErrorInstrumentation { private client: ObservabilityClient private originalHandlers: { globalHandler?: (error: any, isFatal?: boolean) => void consoleError?: (...args: any[]) => void - consoleWarn?: (...args: any[]) => void - unhandledRejection?: (event: any) => void } = {} private isInitialized = false - private originalPromiseThen?: typeof Promise.prototype.then - private unhandledRejections: Set> = new Set() constructor(client: ObservabilityClient) { this.client = client @@ -47,7 +54,6 @@ export class ErrorInstrumentation { try { this.restoreUnhandledExceptionHandler() - this.restorePromiseRejectionHandler() this.restoreConsoleHandlers() this.isInitialized = false this.client._log('ErrorInstrumentation destroyed') @@ -72,19 +78,37 @@ export class ErrorInstrumentation { } private setupUnhandledRejectionHandler(): void { - // Try to set up unhandled rejection handler - try { - // React Native doesn't have native support for unhandledrejection events, - // but we can monkey-patch Promise to catch them - this.patchPromiseRejection() - } catch (error) { - console.warn('Could not setup unhandled rejection handler:', error) + // Use HermesInternal if available (Hermes engine in React Native) + if ( + typeof HermesInternal !== 'undefined' && + HermesInternal?.enablePromiseRejectionTracker + ) { + try { + HermesInternal.enablePromiseRejectionTracker({ + allRejections: true, + onUnhandled: (id: number, error: any) => { + this.client._log( + `Promise rejection unhandled: ${id}`, + error, + ) + + this.handleUnhandledRejection({ reason: error }) + }, + onHandled: (id: number) => { + this.client._log(`Promise rejection handled: ${id}`) + }, + }) + } catch (error) { + this.client._log( + 'Could not setup HermesInternal rejection tracker:', + error, + ) + } } } private setupConsoleErrorHandler(): void { this.originalHandlers.consoleError = console.error - this.originalHandlers.consoleWarn = console.warn console.error = (...args: any[]) => { this.handleConsoleError('error', args) @@ -94,146 +118,6 @@ export class ErrorInstrumentation { } } - private patchPromiseRejection(): void { - this.originalPromiseThen = Promise.prototype.then - - const self = this - const originalThen = this.originalPromiseThen - - Promise.prototype.then = function ( - this: Promise, - onFulfilled?: - | ((value: any) => TResult1 | PromiseLike) - | null - | undefined, - onRejected?: - | ((reason: any) => TResult2 | PromiseLike) - | null - | undefined, - ): Promise { - const thisPromise = this - - // If this promise has a rejection handler, remove it from unhandled set - if (onRejected) { - self.unhandledRejections.delete(thisPromise) - } - - // Call the original then with a wrapped onRejected to track handling - const wrappedOnRejected = onRejected - ? function (reason: any) { - // This rejection is being handled - self.unhandledRejections.delete(thisPromise) - return onRejected(reason) - } - : undefined - - // Call original then - const resultPromise = originalThen.call( - thisPromise, - onFulfilled, - wrappedOnRejected, - ) as Promise - - // If the result promise rejects, we need to track it too - originalThen.call(resultPromise, undefined, function (reason: any) { - // Mark this promise as potentially unhandled - self.unhandledRejections.add(resultPromise) - - // Check after a microtask if it's still unhandled - setTimeout(() => { - if (self.unhandledRejections.has(resultPromise)) { - self.unhandledRejections.delete(resultPromise) - self.handleUnhandledRejection({ reason }) - } - }, 0) - - // Re-throw to preserve rejection - throw reason - }) - - return resultPromise - } as any // Type assertion needed due to Promise patching - - // Also need to track Promise.prototype.catch - const originalCatch = Promise.prototype.catch - Promise.prototype.catch = function (onRejected) { - // Remove from unhandled set when catch is added - self.unhandledRejections.delete(this) - return originalCatch.call(this, onRejected) - } - - // Track rejections from Promise.reject - const originalReject = Promise.reject - Promise.reject = function (reason?: any): Promise { - const promise = originalReject.call(this, reason) as Promise - - // Mark as potentially unhandled - self.unhandledRejections.add(promise) - - // Check after a microtask if it's still unhandled - setTimeout(() => { - if (self.unhandledRejections.has(promise)) { - self.unhandledRejections.delete(promise) - self.handleUnhandledRejection({ reason }) - } - }, 0) - - return promise - } - - // Track rejections from new Promise((resolve, reject) => reject(...)) - const OriginalPromise = Promise - const PromiseConstructor = function ( - this: any, - executor: ( - resolve: (value?: any) => void, - reject: (reason?: any) => void, - ) => void, - ) { - let rejectedReason: any = undefined - let wasRejected = false - - const promise = new OriginalPromise((resolve, reject) => { - const wrappedReject = (reason?: any) => { - // Store rejection info for later processing - wasRejected = true - rejectedReason = reason - reject(reason) - } - - try { - executor(resolve, wrappedReject) - } catch (error) { - wrappedReject(error) - } - }) - - // Now that promise is initialized, we can safely track it - if (wasRejected) { - self.unhandledRejections.add(promise) - - // Check after a microtask if it's still unhandled - setTimeout(() => { - if (self.unhandledRejections.has(promise)) { - self.unhandledRejections.delete(promise) - self.handleUnhandledRejection({ - reason: rejectedReason, - }) - } - }, 0) - } - - return promise - } - - // Copy static methods - Object.setPrototypeOf(PromiseConstructor, OriginalPromise) - PromiseConstructor.prototype = OriginalPromise.prototype - - // Replace global Promise (with type assertion to handle constructor replacement) - ;(global as any).Promise = PromiseConstructor - } - private handleUnhandledException(error: any, isFatal: boolean): void { try { const errorObj = @@ -300,7 +184,7 @@ export class ErrorInstrumentation { } } - private handleConsoleError(level: 'error' | 'warn', args: any[]): void { + private handleConsoleError(level: 'error', args: any[]): void { try { // Convert console arguments to error message const message = parseConsoleArgs(args) @@ -312,8 +196,7 @@ export class ErrorInstrumentation { // Create error from console message const errorObj = new Error(message) - errorObj.name = - level === 'error' ? 'ConsoleError' : 'ConsoleWarning' + errorObj.name = 'ConsoleError' const formattedError = formatError( errorObj, @@ -325,15 +208,12 @@ export class ErrorInstrumentation { const attributes: Attributes = { ...formattedError.attributes, 'error.unhandled': false, - 'error.caught_by': `console.${level}`, - 'console.level': level, + 'error.caught_by': 'console.error', + 'console.level': 'error', 'console.args_count': args.length, } - // Only report console.error by default, console.warn is optional - if (level === 'error') { - this.client.consumeCustomError(errorObj, attributes) - } + this.client.consumeCustomError(errorObj, attributes) } catch (instrumentationError) { console.warn( 'Error in console error instrumentation:', @@ -348,22 +228,9 @@ export class ErrorInstrumentation { } } - private restorePromiseRejectionHandler(): void { - // Restore original Promise.prototype.then if we patched it - if (this.originalPromiseThen) { - Promise.prototype.then = this.originalPromiseThen - } - - // Clear any tracked unhandled rejections - this.unhandledRejections.clear() - } - private restoreConsoleHandlers(): void { if (this.originalHandlers.consoleError) { console.error = this.originalHandlers.consoleError } - if (this.originalHandlers.consoleWarn) { - console.warn = this.originalHandlers.consoleWarn - } } } diff --git a/sdk/@launchdarkly/observability-react-native/src/instrumentation/__tests__/ErrorInstrumentation.test.ts b/sdk/@launchdarkly/observability-react-native/src/instrumentation/__tests__/ErrorInstrumentation.test.ts index c9abe15b7..2b8db652f 100644 --- a/sdk/@launchdarkly/observability-react-native/src/instrumentation/__tests__/ErrorInstrumentation.test.ts +++ b/sdk/@launchdarkly/observability-react-native/src/instrumentation/__tests__/ErrorInstrumentation.test.ts @@ -30,6 +30,19 @@ const mockErrorUtils = { ;(globalThis as any).ErrorUtils = mockErrorUtils +// Mock HermesInternal for promise rejection tracking +let hermesOnUnhandled: ((id: number, error: any) => void) | null = null +let hermesOnHandled: ((id: number) => void) | null = null + +const mockHermesInternal = { + enablePromiseRejectionTracker: vi.fn((options: any) => { + hermesOnUnhandled = options.onUnhandled + hermesOnHandled = options.onHandled + }), +} + +;(globalThis as any).HermesInternal = mockHermesInternal + // Mock console methods const originalConsoleError = console.error const originalConsoleWarn = console.warn @@ -56,6 +69,11 @@ describe('ErrorInstrumentation', () => { vi.clearAllMocks() mockErrorUtils.setGlobalHandler.mockClear() mockErrorUtils.getGlobalHandler.mockClear() + + // Reset Hermes mocks + hermesOnUnhandled = null + hermesOnHandled = null + mockHermesInternal.enablePromiseRejectionTracker.mockClear() }) afterEach(() => { @@ -119,13 +137,14 @@ describe('ErrorInstrumentation', () => { ) errorInstrumentation.initialize() - const testError = new Error('Test promise rejection') + expect( + mockHermesInternal.enablePromiseRejectionTracker, + ).toHaveBeenCalled() - // Create an unhandled promise rejection - Promise.reject(testError) + const testError = new Error('Test promise rejection') - // Wait for the setTimeout in the instrumentation to fire - await new Promise((resolve) => setTimeout(resolve, 20)) + // Simulate Hermes detecting an unhandled rejection + hermesOnUnhandled?.(1, testError) expect(mockClient.consumeCustomError).toHaveBeenCalledWith( testError, @@ -137,17 +156,14 @@ describe('ErrorInstrumentation', () => { ) }) - it('should capture unhandled promise rejections with primitives', async () => { + it('should capture unhandled promise rejections with primitives', () => { errorInstrumentation = new ErrorInstrumentation( mockClient as ObservabilityClient, ) errorInstrumentation.initialize() - // Create an unhandled promise rejection with a string - Promise.reject('String rejection reason') - - // Wait for the setTimeout in the instrumentation to fire - await new Promise((resolve) => setTimeout(resolve, 20)) + // Simulate Hermes detecting an unhandled rejection with a string + hermesOnUnhandled?.(2, 'String rejection reason') expect(mockClient.consumeCustomError).toHaveBeenCalledWith( expect.objectContaining({ @@ -164,20 +180,18 @@ describe('ErrorInstrumentation', () => { ) }) - it('should capture unhandled promise rejections with objects', async () => { + it('should capture unhandled promise rejections with objects', () => { errorInstrumentation = new ErrorInstrumentation( mockClient as ObservabilityClient, ) errorInstrumentation.initialize() - // Create an unhandled promise rejection with an object - Promise.reject({ + // Simulate Hermes detecting an unhandled rejection with an object + hermesOnUnhandled?.(3, { message: 'Custom error object', code: 'ERR_CUSTOM', }) - await new Promise((resolve) => setTimeout(resolve, 20)) - expect(mockClient.consumeCustomError).toHaveBeenCalledWith( expect.objectContaining({ message: 'Custom error object', @@ -192,7 +206,7 @@ describe('ErrorInstrumentation', () => { ) }) - it('should capture unhandled promise rejections from Promise constructor', async () => { + it('should capture unhandled promise rejections from Promise constructor', () => { errorInstrumentation = new ErrorInstrumentation( mockClient as ObservabilityClient, ) @@ -200,12 +214,8 @@ describe('ErrorInstrumentation', () => { const testError = new Error('Constructor rejection') - // Create an unhandled promise rejection using constructor - new Promise((_resolve, reject) => { - reject(testError) - }) - - await new Promise((resolve) => setTimeout(resolve, 20)) + // Simulate Hermes detecting an unhandled rejection + hermesOnUnhandled?.(4, testError) expect(mockClient.consumeCustomError).toHaveBeenCalledWith( testError, @@ -216,43 +226,7 @@ describe('ErrorInstrumentation', () => { ) }) - it('should NOT capture promise rejections that are handled with catch', async () => { - errorInstrumentation = new ErrorInstrumentation( - mockClient as ObservabilityClient, - ) - errorInstrumentation.initialize() - - const testError = new Error('Handled rejection') - - // Create a promise rejection that is handled - Promise.reject(testError).catch(() => { - // Handle the error - }) - - await new Promise((resolve) => setTimeout(resolve, 10)) - - expect(mockClient.consumeCustomError).not.toHaveBeenCalled() - }) - - it('should NOT capture promise rejections that are handled with then', async () => { - errorInstrumentation = new ErrorInstrumentation( - mockClient as ObservabilityClient, - ) - errorInstrumentation.initialize() - - const testError = new Error('Handled rejection') - - // Create a promise rejection that is handled with then's second argument - Promise.reject(testError).then(null, () => { - // Handle the error - }) - - await new Promise((resolve) => setTimeout(resolve, 10)) - - expect(mockClient.consumeCustomError).not.toHaveBeenCalled() - }) - - it('should capture axios-like errors with HTTP details', async () => { + it('should capture axios-like errors with HTTP details', () => { errorInstrumentation = new ErrorInstrumentation( mockClient as ObservabilityClient, ) @@ -271,9 +245,8 @@ describe('ErrorInstrumentation', () => { } ;(axiosError as any).code = 'ERR_BAD_REQUEST' - Promise.reject(axiosError) - - await new Promise((resolve) => setTimeout(resolve, 20)) + // Simulate Hermes detecting an unhandled rejection + hermesOnUnhandled?.(6, axiosError) expect(mockClient.consumeCustomError).toHaveBeenCalledWith( axiosError, @@ -290,15 +263,14 @@ describe('ErrorInstrumentation', () => { ) }) - it('should capture rejections with null or undefined', async () => { + it('should capture rejections with null or undefined', () => { errorInstrumentation = new ErrorInstrumentation( mockClient as ObservabilityClient, ) errorInstrumentation.initialize() - Promise.reject(undefined) - - await new Promise((resolve) => setTimeout(resolve, 20)) + // Simulate Hermes detecting an unhandled rejection with undefined + hermesOnUnhandled?.(7, undefined) expect(mockClient.consumeCustomError).toHaveBeenCalled() const call = (mockClient.consumeCustomError as any).mock.calls[0] diff --git a/sdk/@launchdarkly/observability-react-native/src/instrumentation/errorUtils.ts b/sdk/@launchdarkly/observability-react-native/src/instrumentation/errorUtils.ts index de626fb38..b571f705b 100644 --- a/sdk/@launchdarkly/observability-react-native/src/instrumentation/errorUtils.ts +++ b/sdk/@launchdarkly/observability-react-native/src/instrumentation/errorUtils.ts @@ -223,22 +223,6 @@ export function extractRejectionDetails(reason: any): { return { error, attributes } } -export function isNetworkError(error: Error): boolean { - const networkErrorPatterns = [ - /network/i, - /fetch/i, - /XMLHttpRequest/i, - /CORS/i, - /ERR_NETWORK/i, - /ERR_INTERNET_DISCONNECTED/i, - /ERR_NAME_NOT_RESOLVED/i, - ] - - return networkErrorPatterns.some( - (pattern) => pattern.test(error.message) || pattern.test(error.name), - ) -} - export function shouldSampleError(sampleRate: number): boolean { return Math.random() < sampleRate } From d9cfe42da7b74d31f28fafdd247f22717f7efa30 Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Tue, 7 Oct 2025 15:13:40 -0500 Subject: [PATCH 08/12] Remove app-level handling --- e2e/react-native-otel/app/_layout.tsx | 87 --------------------------- 1 file changed, 87 deletions(-) diff --git a/e2e/react-native-otel/app/_layout.tsx b/e2e/react-native-otel/app/_layout.tsx index 9e27d4e46..75a37a4ec 100644 --- a/e2e/react-native-otel/app/_layout.tsx +++ b/e2e/react-native-otel/app/_layout.tsx @@ -44,93 +44,6 @@ export default function RootLayout() { SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'), }) - // Add global error handlers directly in the React component - useEffect(() => { - // Direct error handler for promise rejections - const errorHandler = (error: any) => { - console.log('[RootLayout] Caught unhandled error:', error) - if (error instanceof Error) { - LDObserve.recordError(error, { - 'error.unhandled': true, - 'error.caught_by': 'root_component_handler', - }) - } - } - - // Register a direct listener for unhandled rejections at the app level - const rejectionHandler = (event: any) => { - const error = event.reason || new Error('Unknown promise rejection') - console.log( - '[RootLayout] Caught unhandled promise rejection:', - error, - ) - LDObserve.recordError( - error instanceof Error ? error : new Error(String(error)), - { - 'error.unhandled': true, - 'error.caught_by': 'root_component_promise_handler', - 'promise.handled': false, - }, - ) - } - - // Network error handler to catch fetch errors - const networkErrorHandler = (error: any) => { - console.log('[RootLayout] Caught network error:', error) - LDObserve.recordError( - error instanceof Error ? error : new Error(String(error)), - { - 'error.unhandled': true, - 'error.caught_by': 'root_component_network_handler', - 'error.type': 'network', - }, - ) - } - - // Set up the handlers - if (global.ErrorUtils) { - const originalGlobalHandler = global.ErrorUtils.getGlobalHandler() - global.ErrorUtils.setGlobalHandler((error, isFatal) => { - errorHandler(error) - if (originalGlobalHandler) { - originalGlobalHandler(error, isFatal) - } - }) - } - - // React Native doesn't fully support the standard addEventListener API for unhandledrejection - // This is a workaround using Promise patches - const originalPromiseReject = Promise.reject - Promise.reject = function (reason) { - const result = originalPromiseReject.call(this, reason) - setTimeout(() => { - // If the rejection isn't handled in the next tick, report it - if (!result._handled) { - rejectionHandler({ reason }) - } - }, 0) - return result - } - - // Patch fetch to catch network errors - const originalFetch = global.fetch - global.fetch = function (...args) { - return originalFetch.apply(this, args).catch((error) => { - networkErrorHandler(error) - throw error // re-throw to preserve original behavior - }) - } as typeof fetch - - return () => { - // Cleanup if component unmounts (unlikely for root layout) - if (global.ErrorUtils && originalGlobalHandler) { - global.ErrorUtils.setGlobalHandler(originalGlobalHandler) - } - Promise.reject = originalPromiseReject - global.fetch = originalFetch - } - }, []) - useEffect(() => { if (loaded) { SplashScreen.hideAsync() From f89cfc8c4f9720c09353b6938fe2e40173aab58d Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Tue, 7 Oct 2025 15:14:50 -0500 Subject: [PATCH 09/12] Update ErrorInstrumentation.ts --- .../src/instrumentation/ErrorInstrumentation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts b/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts index da913577d..9f86498cf 100644 --- a/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts +++ b/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts @@ -169,7 +169,7 @@ export class ErrorInstrumentation { const attributes: Attributes = { ...formattedError.attributes, - ...rejectionAttributes, // Add extracted rejection details + ...rejectionAttributes, 'error.unhandled': true, 'error.caught_by': 'unhandledrejection', 'promise.handled': false, From dada509d54f16a1ed5ed4e9fc375802d46e4b575 Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Wed, 8 Oct 2025 10:11:28 -0500 Subject: [PATCH 10/12] Update ErrorInstrumentation.ts --- .../src/instrumentation/ErrorInstrumentation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts b/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts index 9f86498cf..7b402edce 100644 --- a/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts +++ b/sdk/@launchdarkly/observability-react-native/src/instrumentation/ErrorInstrumentation.ts @@ -100,7 +100,7 @@ export class ErrorInstrumentation { }) } catch (error) { this.client._log( - 'Could not setup HermesInternal rejection tracker:', + 'Could not setup unhandled promise rejection handler:', error, ) } From 786282e2333f2ebd4342130519ec13e8ceed4fcc Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Wed, 8 Oct 2025 13:55:31 -0500 Subject: [PATCH 11/12] dedupe --- yarn.lock | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/yarn.lock b/yarn.lock index 0ab46cce4..b434bcac8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19317,7 +19317,7 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.12.2": +"axios@npm:^1.12.2, axios@npm:^1.6.8": version: 1.12.2 resolution: "axios@npm:1.12.2" dependencies: @@ -19328,17 +19328,6 @@ __metadata: languageName: node linkType: hard -"axios@npm:^1.6.8": - version: 1.8.2 - resolution: "axios@npm:1.8.2" - dependencies: - follow-redirects: "npm:^1.15.6" - form-data: "npm:^4.0.0" - proxy-from-env: "npm:^1.1.0" - checksum: 10/d4328758128d0602cc809a8e7627622cb7839b379eae5e4d6b9d603dd4d5fb89159985630243ec107cf5c675cd8825dba737a319dff9499f3b7688d9a69ec9ed - languageName: node - linkType: hard - "axobject-query@npm:^4.0.0, axobject-query@npm:^4.1.0": version: 4.1.0 resolution: "axobject-query@npm:4.1.0" @@ -26805,19 +26794,7 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.0": - version: 4.0.2 - resolution: "form-data@npm:4.0.2" - dependencies: - asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.8" - es-set-tostringtag: "npm:^2.1.0" - mime-types: "npm:^2.1.12" - checksum: 10/82c65b426af4a40090e517a1bc9057f76970b4c6043e37aa49859c447d88553e77d4cc5626395079a53d2b0889ba5f2a49f3900db3ad3f3f1bf76613532572fb - languageName: node - linkType: hard - -"form-data@npm:^4.0.4": +"form-data@npm:^4.0.0, form-data@npm:^4.0.4": version: 4.0.4 resolution: "form-data@npm:4.0.4" dependencies: From 72d8e09a1c136072cb805eddab0cc148a9e195ec Mon Sep 17 00:00:00 2001 From: ccschmitz Date: Wed, 8 Oct 2025 13:59:09 -0500 Subject: [PATCH 12/12] Clean up error catching/throwing --- e2e/react-native-otel/app/(tabs)/index.tsx | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/e2e/react-native-otel/app/(tabs)/index.tsx b/e2e/react-native-otel/app/(tabs)/index.tsx index 6ca68a06b..cf7422077 100644 --- a/e2e/react-native-otel/app/(tabs)/index.tsx +++ b/e2e/react-native-otel/app/(tabs)/index.tsx @@ -466,25 +466,14 @@ export default function HomeScreen() { const handleAxiosSuccess = async () => { await axios.get('https://jsonplaceholder.typicode.com/posts/1') - Alert.alert('Axios Request', `Request completed`) } const handleAxios404 = async () => { - try { - await axios.get('https://jsonplaceholder.typicode.com/posts/99999') - } catch (error) { - Alert.alert('Axios 404', `Request completed`) - throw error - } + await axios.get('https://jsonplaceholder.typicode.com/posts/99999') } const handleAxios500 = async () => { - try { - await axios.get('https://httpstatuses.maor.io/500') - } catch (error) { - Alert.alert('Axios 500', `Request completed`) - throw error - } + await axios.get('https://httpstatuses.maor.io/500') } return (