From 06e358a6222bb19cb79a9aa0854e4596022f3f44 Mon Sep 17 00:00:00 2001 From: Yi LU Date: Fri, 6 Nov 2026 14:22:03 +0100 Subject: [PATCH] RUM-16390: Fall back to per-thread stack capture when getAllStackTraces fails --- .../error/internal/DatadogExceptionHandler.kt | 20 +- .../internal/DatadogExceptionHandlerTest.kt | 30 +++ dd-sdk-android-internal/api/apiSurface | 3 + .../api/dd-sdk-android-internal.api | 4 + .../internal/utils/ThreadStackTraces.kt | 115 +++++++++ .../internal/utils/ThreadStackTracesTest.kt | 218 ++++++++++++++++++ detekt_custom_unsafe_calls.yml | 1 + .../profiling/internal/utils/ThreadDumper.kt | 34 +-- .../profiling/internal/ThreadDumperTest.kt | 39 ++-- .../ProfilingManagerTriggerRegistrarTest.kt | 4 +- .../rum/internal/anr/ANRDetectorRunnable.kt | 25 +- .../internal/anr/ANRDetectorRunnableTest.kt | 72 ++++++ 12 files changed, 502 insertions(+), 63 deletions(-) create mode 100644 dd-sdk-android-internal/src/main/java/com/datadog/android/internal/utils/ThreadStackTraces.kt create mode 100644 dd-sdk-android-internal/src/test/java/com/datadog/android/internal/utils/ThreadStackTracesTest.kt diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/error/internal/DatadogExceptionHandler.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/error/internal/DatadogExceptionHandler.kt index bdc26ab502..2899b5d4d6 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/error/internal/DatadogExceptionHandler.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/error/internal/DatadogExceptionHandler.kt @@ -16,13 +16,16 @@ import com.datadog.android.core.feature.event.JvmCrash import com.datadog.android.core.feature.event.ThreadDump import com.datadog.android.core.internal.thread.waitToIdle import com.datadog.android.core.internal.utils.triggerUploadWorker +import com.datadog.android.internal.utils.AllStackTracesProvider import com.datadog.android.internal.utils.asString import com.datadog.android.internal.utils.loggableStackTrace +import com.datadog.android.internal.utils.safeGetAllStackTraces import java.util.concurrent.ThreadPoolExecutor internal class DatadogExceptionHandler( private val sdkCore: FeatureSdkCore, - private val appContext: Context + private val appContext: Context, + private val allStackTracesProvider: AllStackTracesProvider = ::safeGetAllStackTraces ) : Thread.UncaughtExceptionHandler { @Volatile @@ -123,7 +126,7 @@ internal class DatadogExceptionHandler( state = crashedThread.state.asString(), stack = e.loggableStackTrace() ) - ) + safeGetAllStacktraces() + ) + captureAllStackTraces() .filterKeys { it != crashedThread } .filterValues { it.isNotEmpty() } .map { (thread, stackTrace) -> @@ -136,18 +139,15 @@ internal class DatadogExceptionHandler( } } - private fun safeGetAllStacktraces(): Map> { - return try { - Thread.getAllStackTraces() - } catch (@Suppress("TooGenericExceptionCaught") t: Throwable) { - // coroutines machinery can throw errors here + private fun captureAllStackTraces(): Map> { + return allStackTracesProvider { message, throwable -> sdkCore.internalLogger.log( InternalLogger.Level.ERROR, InternalLogger.Target.MAINTAINER, - { "Failed to get all threads dump" }, - t + { message }, + throwable, + onlyOnce = true ) - emptyMap() } } diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/error/internal/DatadogExceptionHandlerTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/error/internal/DatadogExceptionHandlerTest.kt index 866ca2f313..222db4a5bb 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/error/internal/DatadogExceptionHandlerTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/error/internal/DatadogExceptionHandlerTest.kt @@ -417,6 +417,36 @@ internal class DatadogExceptionHandlerTest { } } + @Test + fun `M log capture failure once W uncaughtException() { stack capture fails }`( + @StringForgery fakeCaptureMessage: String + ) { + // Given + val captureError = IllegalStateException(fakeCaptureMessage) + testedHandler = DatadogExceptionHandler( + sdkCore = mockSdkCore, + appContext = appContext.mockInstance, + allStackTracesProvider = { onError -> + onError(fakeCaptureMessage, captureError) + emptyMap() + } + ) + testedHandler.register() + val currentThread = Thread.currentThread() + + // When + testedHandler.uncaughtException(currentThread, fakeThrowable) + + // Then + mockInternalLogger.verifyLog( + InternalLogger.Level.ERROR, + InternalLogger.Target.MAINTAINER, + fakeCaptureMessage, + captureError, + onlyOnce = true + ) + } + // endregion private fun Forge.aThrowableWithoutMessage(): Throwable { diff --git a/dd-sdk-android-internal/api/apiSurface b/dd-sdk-android-internal/api/apiSurface index 158db4999d..d92698e497 100644 --- a/dd-sdk-android-internal/api/apiSurface +++ b/dd-sdk-android-internal/api/apiSurface @@ -400,6 +400,9 @@ fun android.view.View.isValidTapTarget(): Boolean fun Thread.safeGetThreadId(): Long fun Thread.State.asString(): String fun Array.loggableStackTrace(): String +typealias StackTraceErrorHandler = (String, Throwable) -> Unit +typealias AllStackTracesProvider = (StackTraceErrorHandler) -> Map> +fun safeGetAllStackTraces(StackTraceErrorHandler): Map> fun Throwable.loggableStackTrace(): String fun Throwable.loggableMessage(): String? fun formatIsoUtc(Long): String diff --git a/dd-sdk-android-internal/api/dd-sdk-android-internal.api b/dd-sdk-android-internal/api/dd-sdk-android-internal.api index 625fe03ecf..ca46a863b9 100644 --- a/dd-sdk-android-internal/api/dd-sdk-android-internal.api +++ b/dd-sdk-android-internal/api/dd-sdk-android-internal.api @@ -989,6 +989,10 @@ public final class com/datadog/android/internal/utils/ThreadExtKt { public static final fun safeGetThreadId (Ljava/lang/Thread;)J } +public final class com/datadog/android/internal/utils/ThreadStackTracesKt { + public static final fun safeGetAllStackTraces (Lkotlin/jvm/functions/Function2;)Ljava/util/Map; +} + public final class com/datadog/android/internal/utils/ThrowableExtKt { public static final fun loggableMessage (Ljava/lang/Throwable;)Ljava/lang/String; public static final fun loggableStackTrace (Ljava/lang/Throwable;)Ljava/lang/String; diff --git a/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/utils/ThreadStackTraces.kt b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/utils/ThreadStackTraces.kt new file mode 100644 index 0000000000..f57929b31f --- /dev/null +++ b/dd-sdk-android-internal/src/main/java/com/datadog/android/internal/utils/ThreadStackTraces.kt @@ -0,0 +1,115 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.internal.utils + +/** + * Receives a message and its cause when a stack trace capture fails. + */ +typealias StackTraceErrorHandler = (message: String, throwable: Throwable) -> Unit + +/** + * Captures the stack traces of all live threads, given a [StackTraceErrorHandler]. + */ +typealias AllStackTracesProvider = (onError: StackTraceErrorHandler) -> Map> + +/** + * Captures the stack traces of all live threads. + * + * Uses [Thread.getAllStackTraces], which fails entirely if a single thread fails. In that case, + * falls back to capturing each live thread separately. In the fallback, recoverable runtime + * failures (a [SecurityException] or any other [RuntimeException] thrown while capturing a + * single thread) are treated as capture failures: they are reported once via [onError], with a + * stable message (so callers can safely use `onlyOnce`) together with the first error + * encountered, and the affected threads are skipped. Fatal [Error]s (such as + * [OutOfMemoryError]) are never caught and propagate. This fallback is not an atomic snapshot: + * threads are captured one at a time. + * + * @param onError called with a message and the cause of each failure. + * @return the stack traces per thread; empty if nothing could be captured. + */ +fun safeGetAllStackTraces(onError: StackTraceErrorHandler): Map> { + return safeGetAllStackTraces( + onError = onError, + bulkStackTracesProvider = { + @Suppress("UnsafeThirdPartyFunctionCall") + Thread.getAllStackTraces() + }, + liveThreadsProvider = ::enumerateLiveThreads, + threadStackTraceProvider = { it.stackTrace } + ) +} + +internal fun safeGetAllStackTraces( + onError: StackTraceErrorHandler, + bulkStackTracesProvider: () -> Map>, + liveThreadsProvider: () -> List, + threadStackTraceProvider: (Thread) -> Array +): Map> { + return try { + bulkStackTracesProvider() + } catch (@Suppress("TooGenericExceptionCaught") t: Throwable) { + // coroutines machinery can throw errors here + onError(BULK_CAPTURE_FAILED, t) + perThreadStackTraces(onError, liveThreadsProvider, threadStackTraceProvider) + } +} + +private fun perThreadStackTraces( + onError: StackTraceErrorHandler, + liveThreadsProvider: () -> List, + threadStackTraceProvider: (Thread) -> Array +): Map> { + val threads = try { + liveThreadsProvider() + } catch (e: SecurityException) { + onError(ENUMERATE_FAILED, e) + return emptyMap() + } + val result = LinkedHashMap>() + var firstFailure: RuntimeException? = null + for (thread in threads) { + try { + result[thread] = threadStackTraceProvider(thread) + } catch ( + // recoverable per-thread runtime failures are skipped and aggregated; fatal Errors + // (e.g. OutOfMemoryError) are not caught and must propagate + @Suppress("TooGenericExceptionCaught") + e: RuntimeException + ) { + if (firstFailure == null) firstFailure = e + } + } + firstFailure?.let { onError(THREAD_CAPTURE_FAILED, it) } + return result +} + +internal fun enumerateLiveThreads(): List { + var root = Thread.currentThread().threadGroup ?: return emptyList() + while (true) root = root.parent ?: break + return enumerateLiveThreads(root) +} + +@Suppress("UnsafeThirdPartyFunctionCall") // SecurityException is handled by perThreadStackTraces +internal fun enumerateLiveThreads(root: ThreadGroup): List { + var buffer = arrayOfNulls(root.activeCount() + THREAD_COUNT_MARGIN) + var count = root.enumerate(buffer, true) + var attempts = 1 + // ponytail: after MAX_ENUMERATE_ATTEMPTS the list may be truncated; fine for a best-effort dump + while (count == buffer.size && attempts < MAX_ENUMERATE_ATTEMPTS) { + buffer = arrayOfNulls(buffer.size * 2) + count = root.enumerate(buffer, true) + attempts++ + } + return buffer.take(count).filterNotNull() +} + +private const val THREAD_COUNT_MARGIN = 16 +private const val MAX_ENUMERATE_ATTEMPTS = 3 +internal const val BULK_CAPTURE_FAILED = "Failed to get all stack traces, falling back to per-thread capture." +internal const val ENUMERATE_FAILED = "Failed to enumerate live threads." +internal const val THREAD_CAPTURE_FAILED = + "Failed to get the stack trace of some thread(s), skipping them." diff --git a/dd-sdk-android-internal/src/test/java/com/datadog/android/internal/utils/ThreadStackTracesTest.kt b/dd-sdk-android-internal/src/test/java/com/datadog/android/internal/utils/ThreadStackTracesTest.kt new file mode 100644 index 0000000000..0b190c890a --- /dev/null +++ b/dd-sdk-android-internal/src/test/java/com/datadog/android/internal/utils/ThreadStackTracesTest.kt @@ -0,0 +1,218 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.internal.utils + +import com.datadog.android.internal.forge.Configurator +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.Extensions + +@Extensions(ExtendWith(ForgeExtension::class)) +@ForgeConfiguration(Configurator::class) +internal class ThreadStackTracesTest { + + private val errors = mutableListOf>() + private val onError: StackTraceErrorHandler = { message, t -> errors.add(message to t) } + + private val fakeStack = Thread.currentThread().stackTrace + + @Test + fun `M return all stack traces W safeGetAllStackTraces() {bulk call succeeds}`( + @StringForgery fakeThreadName: String + ) { + // Given + val expected = mapOf(Thread(fakeThreadName) to fakeStack) + + // When + val result = safeGetAllStackTraces( + onError = onError, + bulkStackTracesProvider = { expected }, + liveThreadsProvider = { error("should not be called") }, + threadStackTraceProvider = { error("should not be called") } + ) + + // Then + assertThat(result).isEqualTo(expected) + assertThat(errors).isEmpty() + } + + @Test + fun `M keep other threads and report failures once W safeGetAllStackTraces() {bulk and threads throw}`( + @StringForgery fakeGoodName: String, + @StringForgery fakeBadName1: String, + @StringForgery fakeBadName2: String, + @StringForgery fakeBulkMessage: String, + @StringForgery fakeThreadMessage: String + ) { + // Given + val goodThread = Thread(fakeGoodName) + val badThread1 = Thread(fakeBadName1) + val badThread2 = Thread(fakeBadName2) + val bulkError = IllegalStateException(fakeBulkMessage) + val firstThreadError = SecurityException(fakeThreadMessage) + + // When + val result = safeGetAllStackTraces( + onError = onError, + bulkStackTracesProvider = { throw bulkError }, + liveThreadsProvider = { listOf(badThread1, goodThread, badThread2) }, + threadStackTraceProvider = { + when (it) { + // SecurityException and IllegalStateException are both recoverable runtime + // failures and must be aggregated together + badThread1 -> throw firstThreadError + badThread2 -> + @Suppress("UseCheckOrError") + throw IllegalStateException() + else -> fakeStack + } + } + ) + + // Then + assertThat(result).containsOnlyKeys(goodThread) + assertThat(result[goodThread]).containsExactly(*fakeStack) + assertThat(errors).containsExactly( + BULK_CAPTURE_FAILED to bulkError, + THREAD_CAPTURE_FAILED to firstThreadError + ) + } + + @Test + fun `M return empty map W safeGetAllStackTraces() {both paths throw}`( + @StringForgery fakeBulkMessage: String, + @StringForgery fakeEnumerateMessage: String + ) { + // Given + val bulkError = IllegalStateException(fakeBulkMessage) + val enumerateError = SecurityException(fakeEnumerateMessage) + + // When + val result = safeGetAllStackTraces( + onError = onError, + bulkStackTracesProvider = { throw bulkError }, + liveThreadsProvider = { throw enumerateError }, + threadStackTraceProvider = { error("should not be called") } + ) + + // Then + assertThat(result).isEmpty() + assertThat(errors).containsExactly( + BULK_CAPTURE_FAILED to bulkError, + ENUMERATE_FAILED to enumerateError + ) + } + + @Test + fun `M capture current thread W safeGetAllStackTraces() {real live thread enumeration}`( + @StringForgery fakeBulkMessage: String + ) { + // Given + val bulkError = IllegalStateException(fakeBulkMessage) + + // When + val result = safeGetAllStackTraces( + onError = onError, + bulkStackTracesProvider = { throw bulkError }, + liveThreadsProvider = ::enumerateLiveThreads, + threadStackTraceProvider = { it.stackTrace } + ) + + // Then + assertThat(result).containsKey(Thread.currentThread()) + assertThat(result[Thread.currentThread()]).isNotEmpty() + } + + @Test + fun `M stop growing the buffer after 3 attempts W enumerateLiveThreads() {thread count keeps growing}`() { + // Given + val fakeGroup = object : ThreadGroup("fake") { + var enumerateCalls = 0 + override fun activeCount(): Int = 5 + override fun enumerate(list: Array, recurse: Boolean): Int { + enumerateCalls++ + return list.size // buffer always full: simulated unbounded thread growth + } + } + + // When + val result = enumerateLiveThreads(fakeGroup) + + // Then + assertThat(fakeGroup.enumerateCalls).isEqualTo(3) + assertThat(result).isEmpty() + } + + @Test + fun `M return captured threads W enumerateLiveThreads() {count fits in buffer}`( + @StringForgery fakeThreadName: String + ) { + // Given + val fakeThread = Thread(fakeThreadName) + val fakeGroup = object : ThreadGroup("fake") { + override fun activeCount(): Int = 1 + override fun enumerate(list: Array, recurse: Boolean): Int { + list[0] = fakeThread + return 1 + } + } + + // When + val result = enumerateLiveThreads(fakeGroup) + + // Then + assertThat(result).containsExactly(fakeThread) + } + + @Test + fun `M propagate OutOfMemoryError W safeGetAllStackTraces() {thread capture runs out of memory}`( + @StringForgery fakeBulkMessage: String, + @StringForgery fakeOomMessage: String + ) { + // Given + val bulkError = IllegalStateException(fakeBulkMessage) + val oomError = OutOfMemoryError(fakeOomMessage) + + // When / Then + assertThatThrownBy { + safeGetAllStackTraces( + onError = onError, + bulkStackTracesProvider = { throw bulkError }, + liveThreadsProvider = { listOf(Thread.currentThread()) }, + threadStackTraceProvider = { throw oomError } + ) + }.isInstanceOf(OutOfMemoryError::class.java) + .isSameAs(oomError) + assertThat(errors).containsExactly(BULK_CAPTURE_FAILED to bulkError) + } + + @Test + fun `M propagate OutOfMemoryError W safeGetAllStackTraces() {enumeration runs out of memory}`( + @StringForgery fakeBulkMessage: String, + @StringForgery fakeOomMessage: String + ) { + // Given + val bulkError = IllegalStateException(fakeBulkMessage) + val oomError = OutOfMemoryError(fakeOomMessage) + + // When / Then + assertThatThrownBy { + safeGetAllStackTraces( + onError = onError, + bulkStackTracesProvider = { throw bulkError }, + liveThreadsProvider = { throw oomError }, + threadStackTraceProvider = { error("should not be called") } + ) + }.isInstanceOf(OutOfMemoryError::class.java) + .isSameAs(oomError) + } +} diff --git a/detekt_custom_unsafe_calls.yml b/detekt_custom_unsafe_calls.yml index 8b96a87b70..733c17e3be 100644 --- a/detekt_custom_unsafe_calls.yml +++ b/detekt_custom_unsafe_calls.yml @@ -117,6 +117,7 @@ datadog: - "java.lang.Thread.constructor(java.lang.Runnable?, kotlin.String?):java.lang.NullPointerException,java.lang.SecurityException,java.lang.IllegalArgumentException" - "java.lang.Thread.constructor(java.lang.Runnable, kotlin.String):java.lang.NullPointerException,java.lang.SecurityException,java.lang.IllegalArgumentException" - "java.lang.Thread.getAllStackTraces():java.lang.SecurityException" + - "java.lang.Thread.getStackTrace():java.lang.SecurityException" - "java.lang.Thread.interrupt():java.lang.SecurityException" - "java.lang.Thread.sleep(kotlin.Long):java.lang.IllegalArgumentException,java.lang.InterruptedException" - "java.util.concurrent.BlockingQueue.drainTo(kotlin.collections.MutableCollection):java.lang.UnsupportedOperationException,java.lang.ClassCastException,java.lang.NullPointerException,java.lang.IllegalArgumentException" diff --git a/features/dd-sdk-android-profiling/src/main/java/com/datadog/android/profiling/internal/utils/ThreadDumper.kt b/features/dd-sdk-android-profiling/src/main/java/com/datadog/android/profiling/internal/utils/ThreadDumper.kt index bcde959f3b..f40c76ff98 100644 --- a/features/dd-sdk-android-profiling/src/main/java/com/datadog/android/profiling/internal/utils/ThreadDumper.kt +++ b/features/dd-sdk-android-profiling/src/main/java/com/datadog/android/profiling/internal/utils/ThreadDumper.kt @@ -10,15 +10,13 @@ import android.os.Looper import com.datadog.android.api.InternalLogger import com.datadog.android.internal.profiling.ProfilingAnrDetectedEvent import com.datadog.android.internal.profiling.ProfilingThreadDump +import com.datadog.android.internal.utils.AllStackTracesProvider import com.datadog.android.internal.utils.loggableStackTrace +import com.datadog.android.internal.utils.safeGetAllStackTraces internal class ThreadDumper( private val mainThreadProvider: () -> Thread = { Looper.getMainLooper().thread }, - private val allStackTracesProvider: () -> Map> = { - @Suppress("UnsafeThirdPartyFunctionCall") - // Caught by try catch block in the call site - Thread.getAllStackTraces() - } + private val allStackTracesProvider: AllStackTracesProvider = ::safeGetAllStackTraces ) { @Volatile @@ -27,7 +25,7 @@ internal class ThreadDumper( fun dump(detectedAtMs: Long): ProfilingAnrDetectedEvent { val mainThread = mainThreadProvider() val anrThreadStack = mainThread.stackTrace.toList() - val allThreads = safeGetAllStackTraces() + val allThreads = allStackTracesProvider(::logError) .filterKeys { it != mainThread } .filterValues { it.isNotEmpty() } .map { (thread, stack) -> @@ -46,21 +44,13 @@ internal class ThreadDumper( ) } - private fun safeGetAllStackTraces(): Map> { - return try { - allStackTracesProvider() - } catch (@Suppress("TooGenericExceptionCaught") t: Throwable) { - internalLogger?.log( - InternalLogger.Level.ERROR, - InternalLogger.Target.MAINTAINER, - { LOG_STACKS_FAILED }, - t - ) - emptyMap() - } - } - - private companion object { - const val LOG_STACKS_FAILED = "Failed to capture all stack traces for thread dump." + private fun logError(message: String, throwable: Throwable) { + internalLogger?.log( + InternalLogger.Level.ERROR, + InternalLogger.Target.MAINTAINER, + { message }, + throwable, + onlyOnce = true + ) } } diff --git a/features/dd-sdk-android-profiling/src/test/kotlin/com/datadog/android/profiling/internal/ThreadDumperTest.kt b/features/dd-sdk-android-profiling/src/test/kotlin/com/datadog/android/profiling/internal/ThreadDumperTest.kt index fdfd73a8cc..9fba1ebba4 100644 --- a/features/dd-sdk-android-profiling/src/test/kotlin/com/datadog/android/profiling/internal/ThreadDumperTest.kt +++ b/features/dd-sdk-android-profiling/src/test/kotlin/com/datadog/android/profiling/internal/ThreadDumperTest.kt @@ -7,10 +7,13 @@ package com.datadog.android.profiling.internal import com.datadog.android.api.InternalLogger +import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.profiling.forge.Configurator import com.datadog.android.profiling.internal.utils.ThreadDumper +import com.datadog.android.utils.verifyLog import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat @@ -20,9 +23,6 @@ import org.junit.jupiter.api.extension.Extensions import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings -import org.mockito.kotlin.any -import org.mockito.kotlin.eq -import org.mockito.kotlin.verify import org.mockito.quality.Strictness @Extensions( @@ -43,7 +43,7 @@ internal class ThreadDumperTest { ) { // Given val anrThread = Thread.currentThread() - val otherThread = Thread("worker") + val otherThread = Thread(forge.anAlphabeticalString()) val otherStack = forge.aList { getForgery() }.toTypedArray() val ignoredStack = forge.aList { getForgery() }.toTypedArray() val testedDumper = ThreadDumper( @@ -65,16 +65,19 @@ internal class ThreadDumperTest { assertThat(dump.anrThreadName).isEqualTo(anrThread.name) assertThat(dump.anrThreadState).isEqualTo(anrThread.state) assertThat(dump.allThreads).hasSize(1) - assertThat(dump.allThreads.first().name).isEqualTo("worker") + assertThat(dump.allThreads.first().name).isEqualTo(otherThread.name) + val expectedStack = otherStack.loggableStackTrace() + assertThat(dump.allThreads.single().stack).isEqualTo(expectedStack) } @Test fun `M skip threads with empty stack W dump()`( + @StringForgery fakeIdleName: String, @LongForgery fakeDetectedAtMs: Long ) { // Given val mainThread = Thread.currentThread() - val idleThread = Thread("idle") + val idleThread = Thread(fakeIdleName) val testedDumper = ThreadDumper( mainThreadProvider = { Thread.currentThread() }, allStackTracesProvider = { @@ -96,14 +99,19 @@ internal class ThreadDumperTest { } @Test - fun `M return empty allThreads and log W getAllStackTraces throws`( + fun `M log error W dump() {stack capture reports error}`( + @StringForgery fakeMessage: String, + @StringForgery fakeErrorMessage: String, @LongForgery fakeDetectedAtMs: Long ) { // Given - val fakeBoom = RuntimeException("boom") + val fakeError = RuntimeException(fakeErrorMessage) val testedDumper = ThreadDumper( mainThreadProvider = { Thread.currentThread() }, - allStackTracesProvider = { throw fakeBoom } + allStackTracesProvider = { onError -> + onError(fakeMessage, fakeError) + emptyMap() + } ).apply { internalLogger = mockInternalLogger } // When @@ -111,13 +119,12 @@ internal class ThreadDumperTest { // Then assertThat(dump.allThreads).isEmpty() - verify(mockInternalLogger).log( - eq(InternalLogger.Level.ERROR), - eq(InternalLogger.Target.MAINTAINER), - any<() -> String>(), - eq(fakeBoom), - eq(false), - eq(null) + mockInternalLogger.verifyLog( + InternalLogger.Level.ERROR, + InternalLogger.Target.MAINTAINER, + fakeMessage, + fakeError, + onlyOnce = true ) } } diff --git a/features/dd-sdk-android-profiling/src/test/kotlin/com/datadog/android/profiling/internal/anr/ProfilingManagerTriggerRegistrarTest.kt b/features/dd-sdk-android-profiling/src/test/kotlin/com/datadog/android/profiling/internal/anr/ProfilingManagerTriggerRegistrarTest.kt index 05127ecf9b..e827e66207 100644 --- a/features/dd-sdk-android-profiling/src/test/kotlin/com/datadog/android/profiling/internal/anr/ProfilingManagerTriggerRegistrarTest.kt +++ b/features/dd-sdk-android-profiling/src/test/kotlin/com/datadog/android/profiling/internal/anr/ProfilingManagerTriggerRegistrarTest.kt @@ -214,7 +214,7 @@ internal class ProfilingManagerTriggerRegistrarTest { } @Test - fun `M deliver empty allThreads W getAllStackTraces throws`( + fun `M deliver empty allThreads W callback accept() {no stack traces captured}`( @TempDir tempDir: File ) { // Given @@ -226,7 +226,7 @@ internal class ProfilingManagerTriggerRegistrarTest { whenever(mockTimeProvider.getDeviceTimestampMillis()).doReturn(creationTimeMs) testedRegistrar.threadDumper = ThreadDumper( mainThreadProvider = { Thread.currentThread() }, - allStackTracesProvider = { throw RuntimeException("boom") } + allStackTracesProvider = { emptyMap() } ) testedRegistrar.register(mockContext, mockListener) val triggerCallbackCaptor = argumentCaptor>() diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/anr/ANRDetectorRunnable.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/anr/ANRDetectorRunnable.kt index eb2a6bf2a6..edc1664eb6 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/anr/ANRDetectorRunnable.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/anr/ANRDetectorRunnable.kt @@ -10,8 +10,10 @@ import android.os.Handler import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.core.feature.event.ThreadDump +import com.datadog.android.internal.utils.AllStackTracesProvider import com.datadog.android.internal.utils.asString import com.datadog.android.internal.utils.loggableStackTrace +import com.datadog.android.internal.utils.safeGetAllStackTraces import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumErrorSource @@ -27,7 +29,8 @@ internal class ANRDetectorRunnable( private val sdkCore: FeatureSdkCore, private val handler: Handler, private val anrThresholdMs: Long = ANR_THRESHOLD_MS, - private val anrTestDelayMs: Long = ANR_TEST_DELAY_MS + private val anrTestDelayMs: Long = ANR_TEST_DELAY_MS, + private val allStackTracesProvider: AllStackTracesProvider = ::safeGetAllStackTraces ) : Runnable { @Volatile @@ -83,15 +86,14 @@ internal class ANRDetectorRunnable( stack = anrException.loggableStackTrace(), crashed = false ) - ) + safeGetAllStacktraces() + ) + captureAllStackTraces() .filterKeys { it != anrThread } .filterValues { it.isNotEmpty() } - .map { - val thread = it.key + .map { (thread, stack) -> ThreadDump( name = thread.name, state = thread.state.asString(), - stack = thread.stackTrace.loggableStackTrace(), + stack = stack.loggableStackTrace(), crashed = false ) } @@ -126,18 +128,15 @@ internal class ANRDetectorRunnable( } } - private fun safeGetAllStacktraces(): Map> { - return try { - Thread.getAllStackTraces() - } catch (@Suppress("TooGenericExceptionCaught") t: Throwable) { - // coroutines machinery can throw errors here + private fun captureAllStackTraces(): Map> { + return allStackTracesProvider { message, throwable -> sdkCore.internalLogger.log( InternalLogger.Level.ERROR, InternalLogger.Target.MAINTAINER, - { "Failed to get all stack traces." }, - t + { message }, + throwable, + onlyOnce = true ) - emptyMap() } } diff --git a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/anr/ANRDetectorRunnableTest.kt b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/anr/ANRDetectorRunnableTest.kt index ff500d70c8..8573093100 100644 --- a/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/anr/ANRDetectorRunnableTest.kt +++ b/features/dd-sdk-android-rum/src/test/kotlin/com/datadog/android/rum/internal/anr/ANRDetectorRunnableTest.kt @@ -8,15 +8,18 @@ package com.datadog.android.rum.internal.anr import android.os.Handler import android.os.Looper +import com.datadog.android.api.InternalLogger import com.datadog.android.core.feature.event.ThreadDump import com.datadog.android.internal.utils.loggableStackTrace import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumErrorSource import com.datadog.android.rum.utils.config.GlobalRumMonitorTestConfiguration import com.datadog.android.rum.utils.forge.Configurator +import com.datadog.android.utils.verifyLog import com.datadog.tools.unit.annotations.TestConfigurationsProvider import com.datadog.tools.unit.extensions.TestConfigurationExtension import com.datadog.tools.unit.extensions.config.TestConfiguration +import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.IntForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -30,6 +33,7 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeast import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq @@ -63,6 +67,9 @@ internal class ANRDetectorRunnableTest { @Mock lateinit var mockLooper: Looper + @Mock + lateinit var mockInternalLogger: InternalLogger + @BeforeEach fun `set up`() { whenever(mockHandler.post(any())) doReturn true @@ -114,6 +121,71 @@ internal class ANRDetectorRunnableTest { } } + @Test + fun `M report provided stack traces and log error W run() {ANR detected, capture reports error}`( + forge: Forge + ) { + // Given + val fakeThread = Thread(forge.anAlphabeticalString()) + val idleThread = Thread(forge.anAlphabeticalString()) + val fakeStack = Thread.currentThread().stackTrace + val fakeMessage = forge.anAlphabeticalString() + val fakeError = SecurityException(forge.anAlphabeticalString()) + whenever(rumMonitor.mockSdkCore.internalLogger) doReturn mockInternalLogger + val errorReported = CountDownLatch(1) + whenever(rumMonitor.mockInstance.addError(any(), any(), any(), any())) doAnswer { + errorReported.countDown() + } + testedRunnable = ANRDetectorRunnable( + rumMonitor.mockSdkCore, + mockHandler, + TEST_ANR_THRESHOLD_MS, + TEST_ANR_TEST_DELAY_MS, + allStackTracesProvider = { onError -> + onError(fakeMessage, fakeError) + mapOf(fakeThread to fakeStack, idleThread to emptyArray()) + } + ) + val runnerThread = Thread(testedRunnable) + + try { + // When + runnerThread.start() + val reported = errorReported.await(TEST_JOIN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + + // Then + assertThat(reported).isTrue() + argumentCaptor> { + verify(rumMonitor.mockInstance).addError( + message = eq("Application Not Responding"), + source = eq(RumErrorSource.SOURCE), + throwable = any(), + attributes = capture() + ) + @Suppress("UNCHECKED_CAST") + val allThreads = lastValue[RumAttributes.INTERNAL_ALL_THREADS] as List + assertThat(allThreads.map { it.name }) + .containsExactly(Thread.currentThread().name, fakeThread.name) + val expectedStack = fakeStack.loggableStackTrace() + assertThat(allThreads[1].stack).isEqualTo(expectedStack) + } + mockInternalLogger.verifyLog( + InternalLogger.Level.ERROR, + InternalLogger.Target.MAINTAINER, + fakeMessage, + fakeError, + onlyOnce = true + ) + } finally { + testedRunnable.stop() + argumentCaptor { + verify(mockHandler, atLeast(0)).post(capture()) + allValues.forEach { it.run() } + } + runnerThread.join(TEST_JOIN_TIMEOUT_MS) + } + } + @Test fun `M not report RUM error W run() {no ANR}`() { // When