Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
62 changes: 62 additions & 0 deletions packages/core/src/js/integrations/nativestackrecovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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