Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

### Fixes

- Recover missing JS stack traces from native `JavascriptException` on Android ([#5964](https://github.com/getsentry/sentry-react-native/pull/5964))
- Lazy-load Metro internal modules to prevent Expo 55 import errors ([#5958](https://github.com/getsentry/sentry-react-native/pull/5958))

### Dependencies
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ class RNSentrySDKTest {
}

private fun verifyDefaults(actualOptions: SentryAndroidOptions) {
assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java))
assertFalse(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java))
assertEquals(RNSentryVersion.ANDROID_SDK_NAME, actualOptions.sdkVersion?.name)
assertEquals(
io.sentry.android.core.BuildConfig.VERSION_NAME,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.app.Activity
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.common.JavascriptException
import io.sentry.Breadcrumb
import io.sentry.Hint
import io.sentry.ILogger
import io.sentry.SentryEvent
import io.sentry.android.core.CurrentActivityHolder
Expand Down Expand Up @@ -32,6 +33,7 @@ class RNSentryStartTest {
MockitoAnnotations.openMocks(this)
logger = mock(ILogger::class.java)
activity = mock(Activity::class.java)
RNSentryJavascriptExceptionCache.clear()
}

@Test
Expand Down Expand Up @@ -196,10 +198,37 @@ class RNSentryStartTest {
}

@Test
fun `the JavascriptException is added to the ignoredExceptionsForType list on with react defaults`() {
fun `the JavascriptException is not added to the ignoredExceptionsForType list`() {
val actualOptions = SentryAndroidOptions()
RNSentryStart.updateWithReactDefaults(actualOptions, activity)
assertTrue(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java))
assertFalse(actualOptions.ignoredExceptionsForType.contains(JavascriptException::class.java))
}

@Test
fun `beforeSend caches JavascriptException stack and drops the event`() {
val options = SentryAndroidOptions()
RNSentryStart.updateWithReactFinals(options)

val jsStackTrace = "TypeError: Cannot read property 'content' of undefined\n at UserMessage (index.android.bundle:1:5274251)"
val jsException = JavascriptException(jsStackTrace)
val event = SentryEvent(jsException)

val result = options.beforeSend?.execute(event, Hint())

assertNull("JavascriptException event should be dropped", result)
val cached = RNSentryJavascriptExceptionCache.getAndClear()
assertEquals(jsStackTrace, cached)
}

@Test
fun `beforeSend does not drop non-JavascriptException events`() {
val options = SentryAndroidOptions()
val event = SentryEvent().apply { sdk = SdkVersion(RNSentryVersion.ANDROID_SDK_NAME, "1.0") }

RNSentryStart.updateWithReactFinals(options)
val result = options.beforeSend?.execute(event, Hint())

assertNotNull("Non-JavascriptException event should not be dropped", result)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package io.sentry.react;

import java.util.concurrent.atomic.AtomicReference;
import org.jetbrains.annotations.Nullable;

/**
* Thread-safe cache for the last JavascriptException stack trace string.
*
* <p>When React Native throws a JavascriptException on Android, the native Sentry SDK intercepts it
* in beforeSend and caches the stack trace here. The JS side can then retrieve it to enrich error
* events that arrive without a stack trace.
*/
final class RNSentryJavascriptExceptionCache {

private static final long TTL_MS = 5000;

private static final AtomicReference<CachedEntry> cache = new AtomicReference<>(null);

private RNSentryJavascriptExceptionCache() {}

static void cache(@Nullable String jsStackTrace) {
if (jsStackTrace == null || jsStackTrace.isEmpty()) {
return;
}
cache.set(new CachedEntry(jsStackTrace, System.currentTimeMillis()));
}

@Nullable
static String getAndClear() {
CachedEntry entry = cache.getAndSet(null);
if (entry == null) {
return null;
}
if (System.currentTimeMillis() - entry.timestampMs > TTL_MS) {
return null;
}
return entry.jsStackTrace;
}

/** Clears the cache. Visible for testing. */
static void clear() {
cache.set(null);
}

private static final class CachedEntry {
final String jsStackTrace;
final long timestampMs;

CachedEntry(String jsStackTrace, long timestampMs) {
this.jsStackTrace = jsStackTrace;
this.timestampMs = timestampMs;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,10 @@ public String fetchNativePackageName() {
return packageInfo.packageName;
}

public String fetchCachedJavascriptExceptionStack() {
return RNSentryJavascriptExceptionCache.getAndClear();
}

public void getDataFromUri(String uri, Promise promise) {
try {
Uri contentUri = Uri.parse(uri);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,10 +297,6 @@ static void updateWithReactDefaults(
options.setTracesSampleRate(null);
options.setTracesSampler(null);

// React native internally throws a JavascriptException.
// we want to ignore it on the native side to avoid sending it twice.
options.addIgnoredExceptionForType(JavascriptException.class);

setCurrentActivity(currentActivity);
}

Expand All @@ -312,6 +308,15 @@ static void updateWithReactFinals(@NotNull SentryAndroidOptions options) {
BeforeSendCallback userBeforeSend = options.getBeforeSend();
options.setBeforeSend(
(event, hint) -> {
// React Native internally throws a JavascriptException when a JS error occurs.
// We cache its stack trace (which may contain frames missing from the JS error)
// and drop the native event to avoid sending duplicates.
Throwable throwable = event.getThrowable();
if (throwable instanceof JavascriptException) {
RNSentryJavascriptExceptionCache.cache(throwable.getMessage());
return null;
}

setEventOriginTag(event);
// Note: In Sentry Android SDK v7, native SDK packages/integrations are already
// included in the SDK version set during initialization, so no need to copy them here.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,11 @@ public String fetchNativePackageName() {
return this.impl.fetchNativePackageName();
}

@Override
public String fetchCachedJavascriptExceptionStack() {
return this.impl.fetchCachedJavascriptExceptionStack();
}

@Override
public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) {
// Not used on Android
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,11 @@ public String fetchNativePackageName() {
return this.impl.fetchNativePackageName();
}

@ReactMethod(isBlockingSynchronousMethod = true)
public String fetchCachedJavascriptExceptionStack() {
return this.impl.fetchCachedJavascriptExceptionStack();
}

@ReactMethod(isBlockingSynchronousMethod = true)
public WritableMap fetchNativeStackFramesBy(ReadableArray instructionsAddr) {
// Not used on Android
Expand Down
6 changes: 6 additions & 0 deletions packages/core/ios/RNSentry.mm
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,12 @@ - (void)handleShakeDetected
return packageName;
}

RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSString *, fetchCachedJavascriptExceptionStack)
{
// Android-only feature, iOS does not need this.
return nil;
}

RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(
NSDictionary *, fetchNativeStackFramesBy : (NSArray *)instructionsAddr)
{
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/js/NativeRNSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface Spec extends TurboModule {
error?: string;
};
fetchNativePackageName(): string | undefined | null;
fetchCachedJavascriptExceptionStack(): string | undefined | null;
fetchNativeStackFramesBy(instructionsAddr: number[]): NativeStackFrames | undefined | null;
initNativeReactNavigationNewFrameTracking(): Promise<void>;
captureReplay(isHardCrash: boolean): Promise<string | undefined | null>;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/js/integrations/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
modulesLoaderIntegration,
nativeLinkedErrorsIntegration,
nativeReleaseIntegration,
nativeStackRecoveryIntegration,
primitiveTagIntegration,
reactNativeErrorHandlersIntegration,
reactNativeInfoIntegration,
Expand Down Expand Up @@ -62,6 +63,7 @@ export function getDefaultIntegrations(options: ReactNativeClientOptions): Integ
}),
);
integrations.push(nativeLinkedErrorsIntegration());
integrations.push(nativeStackRecoveryIntegration());
} else {
integrations.push(browserApiErrorsIntegration());
integrations.push(browserGlobalHandlersIntegration());
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/js/integrations/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { debugSymbolicatorIntegration } from './debugsymbolicator';
export { deviceContextIntegration } from './devicecontext';
export { reactNativeErrorHandlersIntegration } from './reactnativeerrorhandlers';
export { nativeLinkedErrorsIntegration } from './nativelinkederrors';
export { nativeStackRecoveryIntegration } from './nativestackrecovery';
export { nativeReleaseIntegration } from './release';
export { eventOriginIntegration } from './eventorigin';
export { sdkInfoIntegration } from './sdkinfo';
Expand Down
61 changes: 61 additions & 0 deletions packages/core/src/js/integrations/nativestackrecovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Client, Event, EventHint, Integration } from '@sentry/core';

import { debug, parseStackFrames } from '@sentry/core';
import { Platform } from 'react-native';

import { notWeb } from '../utils/environment';
import { NATIVE } from '../wrapper';

const INTEGRATION_NAME = 'NativeStackRecovery';

/**
* Recovers missing JS stack traces from the native JavascriptException cache.
*
* On Android, when a React render error occurs with Hermes, the JS error may arrive
* at the global error handler without a stack trace. However, the same error is caught
* by React Native's native layer as a JavascriptException which contains the full
* JS stack trace. This integration fetches that cached stack and attaches it to the event.
*/
export const nativeStackRecoveryIntegration = (): Integration => {
return {
name: INTEGRATION_NAME,
setupOnce: () => {
/* noop */
},
preprocessEvent: (event: Event, hint: EventHint, client: Client): void => preprocessEvent(event, hint, client),
};
};

function isEnabled(): boolean {
return notWeb() && NATIVE.enableNative && Platform.OS === 'android';
}

function preprocessEvent(event: Event, _hint: EventHint, client: Client): void {
if (!isEnabled()) {
return;
}

const primaryException = event.exception?.values?.[0];
if (!primaryException) {
return;
}

if (primaryException.stacktrace?.frames && primaryException.stacktrace.frames.length > 0) {
return;
}

const cachedStack = NATIVE.fetchCachedJavascriptExceptionStack();
if (!cachedStack) {
return;
}

const parser = client.getOptions().stackParser;
const syntheticError = new Error();
syntheticError.stack = cachedStack;

const frames = parseStackFrames(parser, syntheticError);
if (frames.length > 0) {
primaryException.stacktrace = { frames };
debug.log(`[${INTEGRATION_NAME}] Recovered ${frames.length} frames from native JavascriptException cache`);
}
}
17 changes: 17 additions & 0 deletions packages/core/src/js/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ interface SentryNativeWrapper {

fetchNativePackageName(): string | null;

/**
* Fetches and clears the cached JavascriptException stack trace from the native side.
* Returns null if no cached stack is available or the cache has expired.
*/
fetchCachedJavascriptExceptionStack(): string | null;

/**
* Fetches native stack frames and debug images for the instructions addresses.
*/
Expand Down Expand Up @@ -756,6 +762,17 @@ export const NATIVE: SentryNativeWrapper = {
return RNSentry.fetchNativePackageName() || null;
},

fetchCachedJavascriptExceptionStack(): string | null {
if (!this.enableNative) {
return null;
}
if (!this._isModuleLoaded(RNSentry)) {
return null;
}

return RNSentry.fetchCachedJavascriptExceptionStack() || null;
},

fetchNativeStackFramesBy(instructionsAddr: number[]): NativeStackFrames | null {
if (!this.enableNative) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,24 @@ describe('Integration execution order', () => {
expect(nativeLinkedErrors.preprocessEvent).toHaveBeenCalledBefore(rewriteFrames.processEvent!);
expect(rewriteFrames.processEvent!).toHaveBeenCalledBefore(debugSymbolicator.processEvent!);
});

it('NativeStackRecovery is before RewriteFrames', async () => {
// NativeStackRecovery has to process event before RewriteFrames
// otherwise recovered stack trace frames won't be rewritten

const client = createTestClient();
const { integrations } = client.getOptions();

const nativeStackRecovery = spyOnIntegrationById('NativeStackRecovery', integrations);
const rewriteFrames = spyOnIntegrationById('RewriteFrames', integrations);

client.init();

client.captureException(new Error('test'));
await client.flush();

expect(nativeStackRecovery.preprocessEvent).toHaveBeenCalledBefore(rewriteFrames.processEvent!);
});
});

describe('web', () => {
Expand Down
Loading
Loading