diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ece8bf7e18..d1220d672f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -64,7 +64,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@1b168cd39490f61582a9beae412bb7057a6b2c4e #v4.31.8 + uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 #v4.31.10 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -83,6 +83,6 @@ jobs: ./gradlew --no-daemon assemble - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@1b168cd39490f61582a9beae412bb7057a6b2c4e #v4.31.8 + uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 #v4.31.10 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index f87dfeb0f3..d2975f2383 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -68,7 +68,7 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8 + uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 with: sarif_file: results.sarif diff --git a/CHANGELOG.md b/CHANGELOG.md index a0b91f70e5..46f673fc80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 6.22.0 (2026-01-19) + +### Enhancements + +* Added support for Turbo Module native stacktraces in ``bugsnag-plugin-react-native` + [#2367](https://github.com/bugsnag/bugsnag-android/pull/2367) + +### Bug fixes + +* Replaced the heartbeat lock with park/unpark in the [bugsnag-plugin-android-apphang](bugsnag-plugin-android-apphang) so that the main/monitor threads are not interdependant + [#2363](https://github.com/bugsnag/bugsnag-android/pull/2363) + ## 6.21.0 (2026-01-05) ### Enhancements diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Notifier.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/Notifier.kt index 532c10253f..50361f02f9 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Notifier.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Notifier.kt @@ -7,7 +7,7 @@ import java.io.IOException */ class Notifier @JvmOverloads constructor( var name: String = "Android Bugsnag Notifier", - var version: String = "6.21.0", + var version: String = "6.22.0", var url: String = "https://bugsnag.com" ) : JsonStream.Streamable { diff --git a/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt b/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt index da0290934d..44112b60a9 100644 --- a/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt +++ b/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt @@ -5,7 +5,7 @@ import android.os.Looper import android.os.SystemClock import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.locks.ReentrantLock +import java.util.concurrent.locks.LockSupport internal class LooperMonitorThread( watchedLooper: Looper, @@ -14,15 +14,13 @@ internal class LooperMonitorThread( ) : Thread("Bugsnag AppHang Monitor: ${watchedLooper.thread.name}") { private val handler: Handler = Handler(watchedLooper) + @Volatile private var lastHeartbeatTimestamp = 0L private val isRunning = AtomicBoolean(false) private var isAppHangDetected = false - private val heartbeatLock = ReentrantLock(false) - private val heartbeatCondition = heartbeatLock.newCondition() - private val heartbeat: Runnable = Heartbeat() private fun calculateTimeToAppHang(now: Long): Long = @@ -36,22 +34,17 @@ internal class LooperMonitorThread( fun stopMonitoring() { if (isRunning.compareAndSet(true, false)) { - interrupt() + handler.removeCallbacks(heartbeat) + LockSupport.unpark(this) } } internal fun resetHeartbeatTimer() { - heartbeatLock.lock() - try { - heartbeatCondition.signalAll() - } finally { - heartbeatLock.unlock() - } + LockSupport.unpark(this) } private fun reportAppHang(timeSinceLastHeartbeat: Long) { if (isAppHangDetected) { - // avoid reporting duplicate AppHangs return } @@ -63,37 +56,34 @@ internal class LooperMonitorThread( handler.post(heartbeat) while (isRunning.get()) { - heartbeatLock.lock() - try { - val waitThreshold = - if (lastHeartbeatTimestamp <= 0L) appHangThresholdMillis - else calculateTimeToAppHang(SystemClock.elapsedRealtime()) - heartbeatCondition.await(waitThreshold, TimeUnit.MILLISECONDS) - - val timeSinceLastHeartbeat = SystemClock.elapsedRealtime() - lastHeartbeatTimestamp - - if (timeSinceLastHeartbeat >= appHangThresholdMillis) { - reportAppHang(timeSinceLastHeartbeat) - } - } catch (_: InterruptedException) { - // continue loop and check isRunning - } finally { - heartbeatLock.unlock() + val waitThreshold = + if (lastHeartbeatTimestamp <= 0L) appHangThresholdMillis + else calculateTimeToAppHang(SystemClock.uptimeMillis()) + + val waitThresholdNanos = TimeUnit.MILLISECONDS.toNanos(waitThreshold) + LockSupport.parkNanos(waitThresholdNanos) + + if (!isRunning.get()) break + + val timeSinceLastHeartbeat = SystemClock.uptimeMillis() - lastHeartbeatTimestamp + + if (timeSinceLastHeartbeat >= appHangThresholdMillis) { + reportAppHang(timeSinceLastHeartbeat) + } + + if (!handler.post(heartbeat)) { + // handler.post returning false means the Looper has likely quit + isRunning.set(false) } } } private inner class Heartbeat : Runnable { override fun run() { - lastHeartbeatTimestamp = SystemClock.elapsedRealtime() - // mark the hang as "recovered" and start the detection again + lastHeartbeatTimestamp = SystemClock.uptimeMillis() isAppHangDetected = false - resetHeartbeatTimer() - // only post the Heartbeat messages if the monitor is still running - if (isRunning.get()) { - handler.post(this) - } + resetHeartbeatTimer() } override fun toString(): String { diff --git a/bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/ErrorDeserializer.java b/bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/ErrorDeserializer.java index 1e643d82d8..26e50b3194 100644 --- a/bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/ErrorDeserializer.java +++ b/bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/ErrorDeserializer.java @@ -8,10 +8,14 @@ class ErrorDeserializer implements MapDeserializer { private final StackframeDeserializer stackframeDeserializer; + private final NativeStackDeserializer nativeStackDeserializer; private final Logger logger; - ErrorDeserializer(StackframeDeserializer stackframeDeserializer, Logger logger) { + ErrorDeserializer(StackframeDeserializer stackframeDeserializer, + NativeStackDeserializer nativeStackDeserializer, + Logger logger) { this.stackframeDeserializer = stackframeDeserializer; + this.nativeStackDeserializer = nativeStackDeserializer; this.logger = logger; } @@ -31,6 +35,14 @@ public Error deserialize(Map map) { new Stacktrace(frames), ErrorType.valueOf(type.toUpperCase(Locale.US)) ); - return new Error(impl, logger); + + Error error = new Error(impl, logger); + + if (map.containsKey("nativeStack")) { + List nativeStack = nativeStackDeserializer.deserialize(map); + error.getStacktrace().addAll(0, nativeStack); + } + + return error; } } diff --git a/bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/EventDeserializer.kt b/bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/EventDeserializer.kt index 6563021cdc..1c2e0e2056 100644 --- a/bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/EventDeserializer.kt +++ b/bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/EventDeserializer.kt @@ -10,7 +10,12 @@ internal class EventDeserializer( private val appDeserializer = AppDeserializer() private val deviceDeserializer = DeviceDeserializer() private val stackframeDeserializer = StackframeDeserializer() - private val errorDeserializer = ErrorDeserializer(stackframeDeserializer, client.getLogger()) + private val nativeStackDeserializer = NativeStackDeserializer(projectPackages, client.config) + private val errorDeserializer = ErrorDeserializer( + stackframeDeserializer, + nativeStackDeserializer, + client.getLogger() + ) private val threadDeserializer = ThreadDeserializer(stackframeDeserializer, client.getLogger()) private val breadcrumbDeserializer = BreadcrumbDeserializer(client.getLogger()) @@ -67,8 +72,6 @@ internal class EventDeserializer( if (map.containsKey("nativeStack") && event.errors.isNotEmpty()) { runCatching { val jsError = event.errors.first() - val nativeStackDeserializer = - NativeStackDeserializer(projectPackages, client.config) val nativeStack = nativeStackDeserializer.deserialize(map) jsError.stacktrace.addAll(0, nativeStack) } diff --git a/bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/MapUtils.java b/bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/MapUtils.java index e68dc928ba..ae3ca8cbc7 100644 --- a/bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/MapUtils.java +++ b/bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/MapUtils.java @@ -5,9 +5,14 @@ class MapUtils { @SuppressWarnings("unchecked") - static T getOrNull(Map map, String key) { - Object id = map.get(key); - return id != null ? (T) id : null; + static T getOrNull(Map map, String... keys) { + for (String key : keys) { + Object value = map.get(key); + if (value != null) { + return (T) value; + } + } + return null; } @SuppressWarnings("unchecked") diff --git a/bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/NativeStackDeserializer.java b/bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/NativeStackDeserializer.java index 6024648fc6..037adc74fc 100644 --- a/bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/NativeStackDeserializer.java +++ b/bugsnag-plugin-react-native/src/main/java/com/bugsnag/android/NativeStackDeserializer.java @@ -45,7 +45,7 @@ private Stackframe deserializeStackframe(Map map, methodName = ""; } - String clz = MapUtils.getOrNull(map, "class"); + String clz = MapUtils.getOrNull(map, "className", "class"); String method = clz + "." + methodName; // RN <0.63.2 doesn't add class, gracefully fallback by only reporting @@ -54,9 +54,11 @@ private Stackframe deserializeStackframe(Map map, clz = ""; method = methodName; } + + String file = MapUtils.getOrNull(map, "fileName", "file"); Stackframe stackframe = new Stackframe( method, - MapUtils.getOrNull(map, "file"), + file, MapUtils.getOrNull(map, "lineNumber"), Stacktrace.Companion.inProject(clz, projectPackages) ); diff --git a/bugsnag-plugin-react-native/src/test/java/com/bugsnag/android/ErrorDeserializerTest.kt b/bugsnag-plugin-react-native/src/test/java/com/bugsnag/android/ErrorDeserializerTest.kt index edba6808b4..4d92123e8f 100644 --- a/bugsnag-plugin-react-native/src/test/java/com/bugsnag/android/ErrorDeserializerTest.kt +++ b/bugsnag-plugin-react-native/src/test/java/com/bugsnag/android/ErrorDeserializerTest.kt @@ -1,42 +1,103 @@ package com.bugsnag.android import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test import java.util.HashMap class ErrorDeserializerTest { - private val map = HashMap() - - /** - * Generates a map for verifying the serializer - */ - @Before - fun setup() { + private fun createErrorMap() = HashMap().apply { val frame = HashMap() frame["method"] = "foo()" frame["file"] = "Bar.kt" frame["lineNumber"] = 29 frame["inProject"] = true - map["stacktrace"] = listOf(frame) - map["errorClass"] = "BrowserException" - map["errorMessage"] = "whoops!" - map["type"] = "reactnativejs" + this["stacktrace"] = listOf(frame) + this["errorClass"] = "BrowserException" + this["errorMessage"] = "whoops!" + this["type"] = "reactnativejs" } + private fun createNativeStackFrames(): List> = listOf( + mapOf( + "methodName" to "nativeMethod1", + "lineNumber" to 100, + "fileName" to "Native.java", + "className" to "com.reactnativetest.Native" + ), + mapOf( + "methodName" to "nativeMethod2", + "lineNumber" to 200, + "fileName" to "NativeHelper.kt", + "className" to "com.example.NativeHelper" + ) + ) + @Test - fun deserialize() { - val error = ErrorDeserializer(StackframeDeserializer(), object : Logger {}).deserialize(map) + fun deserializeWithoutNativeStack() { + val map = createErrorMap() + val packages = listOf("com.reactnativetest") + val cfg = TestData.generateConfig() + val nativeStackDeserializer = NativeStackDeserializer(packages, cfg) + val errorDeserializer = ErrorDeserializer( + StackframeDeserializer(), + nativeStackDeserializer, + object : Logger {} + ) + val error = errorDeserializer.deserialize(map) + assertEquals("BrowserException", error.errorClass) assertEquals("whoops!", error.errorMessage) assertEquals(ErrorType.REACTNATIVEJS, error.type) + assertEquals(1, error.stacktrace.size) + + val jsFrame = error.stacktrace[0] + assertEquals("foo()", jsFrame.method) + assertEquals("Bar.kt", jsFrame.file) + assertEquals(29, jsFrame.lineNumber) + assertTrue(jsFrame.inProject as Boolean) + } + + @Test + fun deserializeWithNativeStack() { + val map = createErrorMap() + map["nativeStack"] = createNativeStackFrames() + + val packages = listOf("com.reactnativetest") + val cfg = TestData.generateConfig() + val nativeStackDeserializer = NativeStackDeserializer(packages, cfg) + val errorDeserializer = ErrorDeserializer(StackframeDeserializer(), nativeStackDeserializer, object : Logger {}) + val error = errorDeserializer.deserialize(map) + + assertEquals("BrowserException", error.errorClass) + assertEquals("whoops!", error.errorMessage) + assertEquals(ErrorType.REACTNATIVEJS, error.type) + + // Should have 3 frames total: 2 native frames + 1 JS frame + assertEquals(3, error.stacktrace.size) + + // Native frames should be at the start (indices 0 and 1) + val firstNativeFrame = error.stacktrace[0] + assertEquals("com.reactnativetest.Native.nativeMethod1", firstNativeFrame.method) + assertEquals("Native.java", firstNativeFrame.file) + assertEquals(100, firstNativeFrame.lineNumber) + assertTrue(firstNativeFrame.inProject!!) + assertEquals(ErrorType.ANDROID, firstNativeFrame.type) + + val secondNativeFrame = error.stacktrace[1] + assertEquals("com.example.NativeHelper.nativeMethod2", secondNativeFrame.method) + assertEquals("NativeHelper.kt", secondNativeFrame.file) + assertEquals(200, secondNativeFrame.lineNumber) + assertNull(secondNativeFrame.inProject) + assertEquals(ErrorType.ANDROID, secondNativeFrame.type) - val frame = error.stacktrace[0] - assertEquals("foo()", frame.method) - assertEquals("Bar.kt", frame.file) - assertEquals(29, frame.lineNumber) - assertTrue(frame.inProject as Boolean) + // Original JS frame should now be at index 2 + val jsFrame = error.stacktrace[2] + assertEquals("foo()", jsFrame.method) + assertEquals("Bar.kt", jsFrame.file) + assertEquals(29, jsFrame.lineNumber) + assertTrue(jsFrame.inProject as Boolean) } } diff --git a/bugsnag-plugin-react-native/src/test/java/com/bugsnag/android/EventDeserializerTest.kt b/bugsnag-plugin-react-native/src/test/java/com/bugsnag/android/EventDeserializerTest.kt index 218e995833..d97f939c38 100644 --- a/bugsnag-plugin-react-native/src/test/java/com/bugsnag/android/EventDeserializerTest.kt +++ b/bugsnag-plugin-react-native/src/test/java/com/bugsnag/android/EventDeserializerTest.kt @@ -20,32 +20,8 @@ class EventDeserializerTest { @Mock lateinit var client: Client - private val map = mutableMapOf() - - /** - * Generates a map for verifying the serializer - */ @Before fun setup() { - map["severity"] = "info" - map["unhandled"] = false - map["context"] = "Foo" - map["groupingHash"] = "SomeHash" - map["groupingDiscriminator"] = "SomeDiscriminator" - map["severityReason"] = mapOf(Pair("type", SeverityReason.REASON_HANDLED_EXCEPTION)) - map["user"] = mapOf(Pair("id", "123")) - map["breadcrumbs"] = listOf(breadcrumbMap()) - map["threads"] = listOf(threadMap()) - map["errors"] = listOf(errorMap()) - map["metadata"] = metadataMap() - map["app"] = mapOf(Pair("id", "app-id")) - map["device"] = - mapOf(Pair("id", "device-id"), Pair("runtimeVersions", mutableMapOf())) - map["correlation"] = mapOf( - "traceId" to "b39e53513eec3c68b5e5c34dc43611e0", - "spanId" to "51d886b3a693a406" - ) - `when`(client.config).thenReturn(TestData.generateConfig()) `when`(client.getLogger()).thenReturn(object : Logger {}) `when`(client.getMetadataState()).thenReturn(TestHooks.generateMetadataState()) @@ -80,8 +56,108 @@ class EventDeserializerTest { ) ) + private fun baseEventMap() = mutableMapOf( + "severity" to "info", + "unhandled" to false, + "context" to "Foo", + "groupingHash" to "SomeHash", + "groupingDiscriminator" to "SomeDiscriminator", + "severityReason" to mapOf("type" to SeverityReason.REASON_HANDLED_EXCEPTION), + "user" to mapOf("id" to "123"), + "breadcrumbs" to listOf(breadcrumbMap()), + "threads" to listOf(threadMap()), + "errors" to listOf(errorMap()), + "metadata" to metadataMap(), + "app" to mapOf("id" to "app-id"), + "device" to mapOf("id" to "device-id", "runtimeVersions" to mutableMapOf()), + "correlation" to mapOf( + "traceId" to "b39e53513eec3c68b5e5c34dc43611e0", + "spanId" to "51d886b3a693a406" + ) + ) + + private fun oldNativeStackEventMap() = baseEventMap().apply { + this["errors"] = listOf( + hashMapOf( + "stacktrace" to listOf( + hashMapOf( + "method" to "jsFunction", + "file" to "App.js", + "lineNumber" to 50, + "inProject" to true + ) + ), + "errorClass" to "Error", + "errorMessage" to "Something went wrong", + "type" to "reactnativejs" + ) + ) + this["nativeStack"] = listOf( + mapOf( + "methodName" to "nativeMethod", + "lineNumber" to 42, + "fileName" to "Native.java", + "className" to "com.reactnativetest.Native" + ), + mapOf( + "methodName" to "helperMethod", + "lineNumber" to 99, + "fileName" to "Helper.kt", + "className" to "com.example.Helper" + ) + ) + } + + private fun newNativeStackEventMap() = baseEventMap().apply { + this["errors"] = listOf( + // First error without nativeStack + hashMapOf( + "stacktrace" to listOf( + hashMapOf( + "method" to "firstError", + "file" to "First.js", + "lineNumber" to 10, + "inProject" to true + ) + ), + "errorClass" to "FirstError", + "errorMessage" to "First error", + "type" to "reactnativejs" + ), + // Second error with nativeStack + hashMapOf( + "stacktrace" to listOf( + hashMapOf( + "method" to "secondError", + "file" to "Second.js", + "lineNumber" to 20, + "inProject" to true + ) + ), + "errorClass" to "SecondError", + "errorMessage" to "Second error", + "type" to "reactnativejs", + "nativeStack" to listOf( + mapOf( + "methodName" to "nativeMethod", + "lineNumber" to 42, + "fileName" to "Native.java", + "className" to "com.reactnativetest.Native" + ), + mapOf( + "methodName" to "helperMethod", + "lineNumber" to 99, + "fileName" to "Helper.kt", + "className" to "com.example.Helper" + ) + ) + ) + ) + } + @Test fun deserialize() { + val map = baseEventMap() val event = EventDeserializer(client, emptyList()).deserialize(map) assertNotNull(event) assertEquals(Severity.INFO, event.severity) @@ -109,22 +185,12 @@ class EventDeserializerTest { @Test fun deserializeUnhandledOverridden() { - val map: MutableMap = hashMapOf( - "unhandled" to false, - "severityReason" to hashMapOf( - "type" to "unhandledException", - "unhandledOverridden" to true - ) + val map = baseEventMap() + map["unhandled"] = false + map["severityReason"] = hashMapOf( + "type" to "unhandledException", + "unhandledOverridden" to true ) - map["severity"] = "info" - map["user"] = mapOf(Pair("id", "123")) - map["breadcrumbs"] = listOf(breadcrumbMap()) - map["threads"] = listOf(threadMap()) - map["errors"] = listOf(errorMap()) - map["metadata"] = metadataMap() - map["app"] = mapOf(Pair("id", "app-id")) - map["device"] = - mapOf(Pair("id", "device-id"), Pair("runtimeVersions", mutableMapOf())) val event = EventDeserializer(client, emptyList()).deserialize(map) assertFalse(event.isUnhandled) @@ -133,27 +199,80 @@ class EventDeserializerTest { @Test fun deserializeApiKeyOverridden() { - val map: MutableMap = hashMapOf( - "apiKey" to "abc123", - "severity" to "info", - "user" to mapOf("id" to "123"), - "unhandled" to false, - "severityReason" to hashMapOf( - "type" to "unhandledException", - "unhandledOverridden" to true - ), - "breadcrumbs" to listOf(breadcrumbMap()), - "threads" to listOf(threadMap()), - "errors" to listOf(errorMap()), - "metadata" to metadataMap(), - "app" to mapOf("id" to "app-id"), - "device" to mapOf( - "id" to "device-id", - "runtimeVersions" to hashMapOf() - ) - ) + val map = baseEventMap() + map["apiKey"] = "abc123" val event = EventDeserializer(client, emptyList()).deserialize(map) assertEquals("abc123", event.apiKey) } + + @Test + fun deserializeOldStyleNativeStack() { + // Old style: nativeStack at top level of event, single error + val eventMap = oldNativeStackEventMap() + val event = EventDeserializer(client, listOf("com.reactnativetest")).deserialize(eventMap) + assertEquals(1, event.errors.size) + + val error = event.errors[0] + // Should have 3 frames: 2 native + 1 JS + assertEquals(3, error.stacktrace.size) + + // Native frames should be at the start + val firstNativeFrame = error.stacktrace[0] + assertEquals("com.reactnativetest.Native.nativeMethod", firstNativeFrame.method) + assertEquals("Native.java", firstNativeFrame.file) + assertEquals(42, firstNativeFrame.lineNumber) + assertEquals(ErrorType.ANDROID, firstNativeFrame.type) + assertTrue(firstNativeFrame.inProject!!) + + val secondNativeFrame = error.stacktrace[1] + assertEquals("com.example.Helper.helperMethod", secondNativeFrame.method) + assertEquals("Helper.kt", secondNativeFrame.file) + assertEquals(99, secondNativeFrame.lineNumber) + assertEquals(ErrorType.ANDROID, secondNativeFrame.type) + + // Original JS frame should be at index 2 + val jsFrame = error.stacktrace[2] + assertEquals("jsFunction", jsFrame.method) + assertEquals("App.js", jsFrame.file) + assertEquals(50, jsFrame.lineNumber) + } + + @Test + fun deserializeNewStyleNativeStack() { + // New style: nativeStack per error, multiple errors + val eventMap = newNativeStackEventMap() + val event = EventDeserializer(client, listOf("com.reactnativetest")).deserialize(eventMap) + assertEquals(2, event.errors.size) + + // First error should have only its original frame + val firstError = event.errors[0] + assertEquals(1, firstError.stacktrace.size) + assertEquals("firstError", firstError.stacktrace[0].method) + assertEquals("First.js", firstError.stacktrace[0].file) + + // Second error should have native frames prepended + val secondError = event.errors[1] + assertEquals(3, secondError.stacktrace.size) + + // Native frames should be at the start + val firstNativeFrame = secondError.stacktrace[0] + assertEquals("com.reactnativetest.Native.nativeMethod", firstNativeFrame.method) + assertEquals("Native.java", firstNativeFrame.file) + assertEquals(42, firstNativeFrame.lineNumber) + assertEquals(ErrorType.ANDROID, firstNativeFrame.type) + assertTrue(firstNativeFrame.inProject!!) + + val secondNativeFrame = secondError.stacktrace[1] + assertEquals("com.example.Helper.helperMethod", secondNativeFrame.method) + assertEquals("Helper.kt", secondNativeFrame.file) + assertEquals(99, secondNativeFrame.lineNumber) + assertEquals(ErrorType.ANDROID, secondNativeFrame.type) + + // Original JS frame should be at index 2 + val jsFrame = secondError.stacktrace[2] + assertEquals("secondError", jsFrame.method) + assertEquals("Second.js", jsFrame.file) + assertEquals(20, jsFrame.lineNumber) + } } diff --git a/bugsnag-plugin-react-native/src/test/java/com/bugsnag/android/NativeStackDeserializerTest.kt b/bugsnag-plugin-react-native/src/test/java/com/bugsnag/android/NativeStackDeserializerTest.kt index b90f78bb7c..ef6d8966f7 100644 --- a/bugsnag-plugin-react-native/src/test/java/com/bugsnag/android/NativeStackDeserializerTest.kt +++ b/bugsnag-plugin-react-native/src/test/java/com/bugsnag/android/NativeStackDeserializerTest.kt @@ -3,63 +3,77 @@ package com.bugsnag.android import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized -class NativeStackDeserializerTest { +@RunWith(Parameterized::class) +class NativeStackDeserializerTest( + @Suppress("unused") + private val testName: String, + private val nativeStackMap: Map +) { - private lateinit var map: Map + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): Collection> { + return listOf( + arrayOf("Old Architecture", createOldArchNativeStackMap()), + arrayOf("New Architecture", createNewArchNativeStackMap()) + ) + } - /** - * Generates a map for verifying the serializer - */ - @Before - fun setup() { - val errorStacktrace = listOf( - mapOf( - "method" to "foo()", - "file" to "Bar.kt", - "lineNumber" to 29, - "inProject" to true + private fun createOldArchNativeStackMap(): Map = mapOf( + "nativeStack" to listOf( + mapOf( + "methodName" to "asyncReject", + "lineNumber" to 42, + "file" to "BenCrash.java", + "class" to "com.reactnativetest.BenCrash" + ), + mapOf( + "methodName" to "invokeFoo", + "lineNumber" to 57, + "file" to "Foo.kt", + "class" to "com.example.Foo" + ), + mapOf( + "methodName" to "invokeWham", + "lineNumber" to 159, + "file" to "Wham.kt" + ) ) ) - val error = mapOf( - "stacktrace" to errorStacktrace, - "errorClass" to "BrowserException", - "errorMessage" to "whoops!", - "type" to "reactnativejs" - ) - val nativeStack = listOf( - mapOf( - "methodName" to "asyncReject", - "lineNumber" to 42, - "file" to "BenCrash.java", - "class" to "com.reactnativetest.BenCrash" - ), - mapOf( - "methodName" to "invokeFoo", - "lineNumber" to 57, - "file" to "Foo.kt", - "class" to "com.example.Foo" - ), - mapOf( - "methodName" to "invokeWham", - "lineNumber" to 159, - "file" to "Wham.kt" + private fun createNewArchNativeStackMap(): Map = mapOf( + "nativeStack" to listOf( + mapOf( + "methodName" to "asyncReject", + "lineNumber" to 42, + "fileName" to "BenCrash.java", + "className" to "com.reactnativetest.BenCrash" + ), + mapOf( + "methodName" to "invokeFoo", + "lineNumber" to 57, + "fileName" to "Foo.kt", + "className" to "com.example.Foo" + ), + mapOf( + "methodName" to "invokeWham", + "lineNumber" to 159, + "fileName" to "Wham.kt" + ) ) ) - map = mapOf( - "errors" to listOf(error), - "nativeStack" to nativeStack - ) } @Test - fun deserialize() { + fun deserializeNativeStack() { val packages = listOf("com.reactnativetest") val cfg = TestData.generateConfig() - val nativeStack = NativeStackDeserializer(packages, cfg).deserialize(map) + val nativeStack = NativeStackDeserializer(packages, cfg).deserialize(nativeStackMap) assertEquals(3, nativeStack.size) val firstFrame = nativeStack[0] @@ -80,7 +94,7 @@ class NativeStackDeserializerTest { assertEquals("invokeWham", thirdFrame.method) assertEquals("Wham.kt", thirdFrame.file) assertEquals(159, thirdFrame.lineNumber) - assertNull(secondFrame.inProject) + assertNull(thirdFrame.inProject) assertEquals(ErrorType.ANDROID, thirdFrame.type) } } diff --git a/examples/sdk-app-example/gradle/libs.versions.toml b/examples/sdk-app-example/gradle/libs.versions.toml index 221e15621e..3458890373 100644 --- a/examples/sdk-app-example/gradle/libs.versions.toml +++ b/examples/sdk-app-example/gradle/libs.versions.toml @@ -2,7 +2,7 @@ activityCompose = "1.8.0" agp = "8.10.0" appcompat = "1.6.1" -bugsnag-android = "6.21.0" +bugsnag-android = "6.22.0" bugsnag-gradle = "0.4.0" composeBom = "2024.09.00" coreKtx = "1.16.0" diff --git a/gradle.properties b/gradle.properties index 6cf0322e50..508b808d81 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,7 @@ org.gradle.jvmargs=-Xmx4096m # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects org.gradle.parallel=true -VERSION_NAME=6.21.0 +VERSION_NAME=6.22.0 GROUP=com.bugsnag POM_SCM_URL=https://github.com/bugsnag/bugsnag-android POM_SCM_CONNECTION=scm:git@github.com:bugsnag/bugsnag-android.git