diff --git a/platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/events/lifecycle/AppExitLogger.kt b/platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/events/lifecycle/AppExitLogger.kt index b32d4ec8..e0542aa7 100644 --- a/platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/events/lifecycle/AppExitLogger.kt +++ b/platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/events/lifecycle/AppExitLogger.kt @@ -140,18 +140,37 @@ internal class AppExitLogger( @TargetApi(Build.VERSION_CODES.R) private fun ApplicationExitInfo.toFields(): InternalFieldsMap { - // https://developer.android.com/reference/kotlin/android/app/ApplicationExitInfo - return mapOf( + // Initialize the fields map with basic application exit information + val fields = mutableMapOf( "_app_exit_source" to "ApplicationExitInfo", - "_app_exit_process_name" to this.processName, - "_app_exit_reason" to this.reason.toReasonText(), - "_app_exit_importance" to this.importance.toImportanceText(), - "_app_exit_status" to this.status.toString(), - "_app_exit_pss" to this.pss.toString(), - "_app_exit_rss" to this.rss.toString(), - "_app_exit_description" to this.description.orEmpty(), - // TODO(murki): Extract getTraceInputStream() for REASON_ANR or REASON_CRASH_NATIVE - ).toFields() + "_app_exit_process_name" to processName, + "_app_exit_reason" to reason.toReasonText(), + "_app_exit_importance" to importance.toImportanceText(), + "_app_exit_status" to status.toString(), + "_app_exit_pss" to pss.toString(), + "_app_exit_rss" to rss.toString(), + "_app_exit_description" to description.orEmpty() + ) + + // Add trace data if available and the reason matches REASON_ANR or REASON_CRASH_NATIVE + if (reason == ApplicationExitInfo.REASON_ANR + || reason == ApplicationExitInfo.REASON_CRASH_NATIVE) { + val traceData = getTraceInputStream()?.use { traceStream -> + // Check if there is any data in the stream + if (traceStream.available() > 0) { + traceStream.bufferedReader().use { it.readText() } + } else { + null + } + } + + // Only add _app_exit_trace if traceData has content + if (!traceData.isNullOrEmpty()) { + fields["_app_exit_trace"] = traceData + } + } + + return fields.toFields() } private fun Int.toReasonText(): String { diff --git a/platform/jvm/capture/src/test/kotlin/io/bitdrift/capture/AppExitLoggerTest.kt b/platform/jvm/capture/src/test/kotlin/io/bitdrift/capture/AppExitLoggerTest.kt index 2cedde83..0102d81b 100644 --- a/platform/jvm/capture/src/test/kotlin/io/bitdrift/capture/AppExitLoggerTest.kt +++ b/platform/jvm/capture/src/test/kotlin/io/bitdrift/capture/AppExitLoggerTest.kt @@ -29,6 +29,7 @@ import org.junit.Test import org.mockito.ArgumentMatchers.anyInt import org.mockito.Mockito import org.mockito.Mockito.RETURNS_DEEP_STUBS +import java.io.ByteArrayInputStream import java.io.IOException import java.nio.charset.StandardCharsets @@ -168,6 +169,101 @@ class AppExitLoggerTest { ) } + @Test + fun testLogPreviousExitReasonIfAnyWithTrace() { + // ARRANGE + val sessionId = "test-session-id" + val timestamp = 123L + val traceData = "Test trace data" + + val mockExitInfo = mock(defaultAnswer = RETURNS_DEEP_STUBS) + whenever(mockExitInfo.processStateSummary).thenReturn(sessionId.toByteArray(StandardCharsets.UTF_8)) + whenever(mockExitInfo.timestamp).thenReturn(timestamp) + whenever(mockExitInfo.processName).thenReturn("test-process-name") + whenever(mockExitInfo.reason).thenReturn(ApplicationExitInfo.REASON_ANR) + whenever(mockExitInfo.importance).thenReturn(RunningAppProcessInfo.IMPORTANCE_FOREGROUND) + whenever(mockExitInfo.status).thenReturn(0) + whenever(mockExitInfo.pss).thenReturn(1) + whenever(mockExitInfo.rss).thenReturn(2) + whenever(mockExitInfo.description).thenReturn("test-description") + whenever(mockExitInfo.getTraceInputStream()).thenReturn(ByteArrayInputStream(traceData.toByteArray())) + + whenever(activityManager.getHistoricalProcessExitReasons(anyOrNull(), any(), any())).thenReturn(listOf(mockExitInfo)) + + // ACT + appExitLogger.logPreviousExitReasonIfAny() + + // ASSERT + val expectedFields = mapOf( + "_app_exit_source" to "ApplicationExitInfo", + "_app_exit_process_name" to "test-process-name", + "_app_exit_reason" to "ANR", + "_app_exit_importance" to "FOREGROUND", + "_app_exit_status" to "0", + "_app_exit_pss" to "1", + "_app_exit_rss" to "2", + "_app_exit_description" to "test-description", + "_app_exit_trace" to traceData + ).toFields() + + verify(logger).log( + eq(LogType.LIFECYCLE), + eq(LogLevel.ERROR), + eq(expectedFields), + eq(null), + eq(LogAttributesOverrides(sessionId, timestamp)), + eq(false), + argThat { i: () -> String -> i.invoke() == "AppExit" }, + ) + } + + @Test + fun testLogPreviousExitReasonIfAnyWithoutTrace() { + // ARRANGE + val sessionId = "test-session-id" + val timestamp = 123L + + val mockExitInfo = mock(defaultAnswer = RETURNS_DEEP_STUBS) + whenever(mockExitInfo.processStateSummary).thenReturn(sessionId.toByteArray(StandardCharsets.UTF_8)) + whenever(mockExitInfo.timestamp).thenReturn(timestamp) + whenever(mockExitInfo.processName).thenReturn("test-process-name") + whenever(mockExitInfo.reason).thenReturn(ApplicationExitInfo.REASON_ANR) + whenever(mockExitInfo.importance).thenReturn(RunningAppProcessInfo.IMPORTANCE_FOREGROUND) + whenever(mockExitInfo.status).thenReturn(0) + whenever(mockExitInfo.pss).thenReturn(1) + whenever(mockExitInfo.rss).thenReturn(2) + whenever(mockExitInfo.description).thenReturn("test-description") + whenever(mockExitInfo.getTraceInputStream()).thenReturn(null) // Simulate no trace data available + + whenever(activityManager.getHistoricalProcessExitReasons(anyOrNull(), any(), any())).thenReturn(listOf(mockExitInfo)) + + // ACT + appExitLogger.logPreviousExitReasonIfAny() + + // ASSERT + val expectedFields = mapOf( + "_app_exit_source" to "ApplicationExitInfo", + "_app_exit_process_name" to "test-process-name", + "_app_exit_reason" to "ANR", + "_app_exit_importance" to "FOREGROUND", + "_app_exit_status" to "0", + "_app_exit_pss" to "1", + "_app_exit_rss" to "2", + "_app_exit_description" to "test-description" + // _app_exit_trace is not present + ).toFields() + + verify(logger).log( + eq(LogType.LIFECYCLE), + eq(LogLevel.ERROR), + eq(expectedFields), + eq(null), + eq(LogAttributesOverrides(sessionId, timestamp)), + eq(false), + argThat { i: () -> String -> i.invoke() == "AppExit" }, + ) + } + @Test fun testLogPreviousExitReasonIfAnyReportsError() { // ARRANGE