Skip to content

Commit 54acba0

Browse files
committed
feat(plugin-react-native): deserialize native stacktraces from turbo module errors
1 parent 20694de commit 54acba0

8 files changed

Lines changed: 354 additions & 131 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## TBD
4+
5+
### Enhancements
6+
7+
* Added support for Turbo Module native stacktraces in ``bugsnag-plugin-react-native`
8+
[#2367](https://github.com/bugsnag/bugsnag-android/pull/2367)
9+
310
## 6.21.0 (2026-01-05)
411

512
### Enhancements

bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/ErrorDeserializer.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@
88
class ErrorDeserializer implements MapDeserializer<Error> {
99

1010
private final StackframeDeserializer stackframeDeserializer;
11+
private final NativeStackDeserializer nativeStackDeserializer;
1112
private final Logger logger;
1213

13-
ErrorDeserializer(StackframeDeserializer stackframeDeserializer, Logger logger) {
14+
ErrorDeserializer(StackframeDeserializer stackframeDeserializer,
15+
NativeStackDeserializer nativeStackDeserializer,
16+
Logger logger) {
1417
this.stackframeDeserializer = stackframeDeserializer;
18+
this.nativeStackDeserializer = nativeStackDeserializer;
1519
this.logger = logger;
1620
}
1721

@@ -31,6 +35,14 @@ public Error deserialize(Map<String, Object> map) {
3135
new Stacktrace(frames),
3236
ErrorType.valueOf(type.toUpperCase(Locale.US))
3337
);
34-
return new Error(impl, logger);
38+
39+
Error error = new Error(impl, logger);
40+
41+
if (map.containsKey("nativeStack")) {
42+
List<Stackframe> nativeStack = nativeStackDeserializer.deserialize(map);
43+
error.getStacktrace().addAll(0, nativeStack);
44+
}
45+
46+
return error;
3547
}
3648
}

bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/EventDeserializer.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ internal class EventDeserializer(
1010
private val appDeserializer = AppDeserializer()
1111
private val deviceDeserializer = DeviceDeserializer()
1212
private val stackframeDeserializer = StackframeDeserializer()
13-
private val errorDeserializer = ErrorDeserializer(stackframeDeserializer, client.getLogger())
13+
private val nativeStackDeserializer = NativeStackDeserializer(projectPackages, client.config)
14+
private val errorDeserializer = ErrorDeserializer(
15+
stackframeDeserializer,
16+
nativeStackDeserializer,
17+
client.getLogger()
18+
)
1419
private val threadDeserializer = ThreadDeserializer(stackframeDeserializer, client.getLogger())
1520
private val breadcrumbDeserializer = BreadcrumbDeserializer(client.getLogger())
1621

@@ -67,8 +72,6 @@ internal class EventDeserializer(
6772
if (map.containsKey("nativeStack") && event.errors.isNotEmpty()) {
6873
runCatching {
6974
val jsError = event.errors.first()
70-
val nativeStackDeserializer =
71-
NativeStackDeserializer(projectPackages, client.config)
7275
val nativeStack = nativeStackDeserializer.deserialize(map)
7376
jsError.stacktrace.addAll(0, nativeStack)
7477
}

bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/MapUtils.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@
55
class MapUtils {
66

77
@SuppressWarnings("unchecked")
8-
static <T> T getOrNull(Map<String, Object> map, String key) {
9-
Object id = map.get(key);
10-
return id != null ? (T) id : null;
8+
static <T> T getOrNull(Map<String, Object> map, String... keys) {
9+
for (String key : keys) {
10+
Object value = map.get(key);
11+
if (value != null) {
12+
return (T) value;
13+
}
14+
}
15+
return null;
1116
}
1217

1318
@SuppressWarnings("unchecked")

bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/NativeStackDeserializer.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ private Stackframe deserializeStackframe(Map<String, Object> map,
4545
methodName = "";
4646
}
4747

48-
String clz = MapUtils.getOrNull(map, "class");
48+
String clz = MapUtils.getOrNull(map, "className", "class");
4949
String method = clz + "." + methodName;
5050

5151
// RN <0.63.2 doesn't add class, gracefully fallback by only reporting
@@ -54,9 +54,11 @@ private Stackframe deserializeStackframe(Map<String, Object> map,
5454
clz = "";
5555
method = methodName;
5656
}
57+
58+
String file = MapUtils.getOrNull(map, "fileName", "file");
5759
Stackframe stackframe = new Stackframe(
5860
method,
59-
MapUtils.<String>getOrNull(map, "file"),
61+
file,
6062
MapUtils.<Integer>getOrNull(map, "lineNumber"),
6163
Stacktrace.Companion.inProject(clz, projectPackages)
6264
);
Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,103 @@
11
package com.bugsnag.android
22

33
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertNull
45
import org.junit.Assert.assertTrue
5-
import org.junit.Before
66
import org.junit.Test
77
import java.util.HashMap
88

99
class ErrorDeserializerTest {
1010

11-
private val map = HashMap<String, Any>()
12-
13-
/**
14-
* Generates a map for verifying the serializer
15-
*/
16-
@Before
17-
fun setup() {
11+
private fun createErrorMap() = HashMap<String, Any>().apply {
1812
val frame = HashMap<String, Any>()
1913
frame["method"] = "foo()"
2014
frame["file"] = "Bar.kt"
2115
frame["lineNumber"] = 29
2216
frame["inProject"] = true
23-
map["stacktrace"] = listOf(frame)
24-
map["errorClass"] = "BrowserException"
25-
map["errorMessage"] = "whoops!"
26-
map["type"] = "reactnativejs"
17+
this["stacktrace"] = listOf(frame)
18+
this["errorClass"] = "BrowserException"
19+
this["errorMessage"] = "whoops!"
20+
this["type"] = "reactnativejs"
2721
}
2822

23+
private fun createNativeStackFrames(): List<Map<String, Any>> = listOf(
24+
mapOf(
25+
"methodName" to "nativeMethod1",
26+
"lineNumber" to 100,
27+
"fileName" to "Native.java",
28+
"className" to "com.reactnativetest.Native"
29+
),
30+
mapOf(
31+
"methodName" to "nativeMethod2",
32+
"lineNumber" to 200,
33+
"fileName" to "NativeHelper.kt",
34+
"className" to "com.example.NativeHelper"
35+
)
36+
)
37+
2938
@Test
30-
fun deserialize() {
31-
val error = ErrorDeserializer(StackframeDeserializer(), object : Logger {}).deserialize(map)
39+
fun deserializeWithoutNativeStack() {
40+
val map = createErrorMap()
41+
val packages = listOf("com.reactnativetest")
42+
val cfg = TestData.generateConfig()
43+
val nativeStackDeserializer = NativeStackDeserializer(packages, cfg)
44+
val errorDeserializer = ErrorDeserializer(
45+
StackframeDeserializer(),
46+
nativeStackDeserializer,
47+
object : Logger {}
48+
)
49+
val error = errorDeserializer.deserialize(map)
50+
3251
assertEquals("BrowserException", error.errorClass)
3352
assertEquals("whoops!", error.errorMessage)
3453
assertEquals(ErrorType.REACTNATIVEJS, error.type)
54+
assertEquals(1, error.stacktrace.size)
55+
56+
val jsFrame = error.stacktrace[0]
57+
assertEquals("foo()", jsFrame.method)
58+
assertEquals("Bar.kt", jsFrame.file)
59+
assertEquals(29, jsFrame.lineNumber)
60+
assertTrue(jsFrame.inProject as Boolean)
61+
}
62+
63+
@Test
64+
fun deserializeWithNativeStack() {
65+
val map = createErrorMap()
66+
map["nativeStack"] = createNativeStackFrames()
67+
68+
val packages = listOf("com.reactnativetest")
69+
val cfg = TestData.generateConfig()
70+
val nativeStackDeserializer = NativeStackDeserializer(packages, cfg)
71+
val errorDeserializer = ErrorDeserializer(StackframeDeserializer(), nativeStackDeserializer, object : Logger {})
72+
val error = errorDeserializer.deserialize(map)
73+
74+
assertEquals("BrowserException", error.errorClass)
75+
assertEquals("whoops!", error.errorMessage)
76+
assertEquals(ErrorType.REACTNATIVEJS, error.type)
77+
78+
// Should have 3 frames total: 2 native frames + 1 JS frame
79+
assertEquals(3, error.stacktrace.size)
80+
81+
// Native frames should be at the start (indices 0 and 1)
82+
val firstNativeFrame = error.stacktrace[0]
83+
assertEquals("com.reactnativetest.Native.nativeMethod1", firstNativeFrame.method)
84+
assertEquals("Native.java", firstNativeFrame.file)
85+
assertEquals(100, firstNativeFrame.lineNumber)
86+
assertTrue(firstNativeFrame.inProject!!)
87+
assertEquals(ErrorType.ANDROID, firstNativeFrame.type)
88+
89+
val secondNativeFrame = error.stacktrace[1]
90+
assertEquals("com.example.NativeHelper.nativeMethod2", secondNativeFrame.method)
91+
assertEquals("NativeHelper.kt", secondNativeFrame.file)
92+
assertEquals(200, secondNativeFrame.lineNumber)
93+
assertNull(secondNativeFrame.inProject)
94+
assertEquals(ErrorType.ANDROID, secondNativeFrame.type)
3595

36-
val frame = error.stacktrace[0]
37-
assertEquals("foo()", frame.method)
38-
assertEquals("Bar.kt", frame.file)
39-
assertEquals(29, frame.lineNumber)
40-
assertTrue(frame.inProject as Boolean)
96+
// Original JS frame should now be at index 2
97+
val jsFrame = error.stacktrace[2]
98+
assertEquals("foo()", jsFrame.method)
99+
assertEquals("Bar.kt", jsFrame.file)
100+
assertEquals(29, jsFrame.lineNumber)
101+
assertTrue(jsFrame.inProject as Boolean)
41102
}
42103
}

0 commit comments

Comments
 (0)