Skip to content

feat: optimized IO file logger #4078

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package com.wire.android.ui.debug
import androidx.compose.ui.test.junit4.createComposeRule
import com.wire.android.extensions.waitUntilExists
import com.wire.android.ui.WireTestTheme
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
Expand All @@ -41,6 +42,7 @@ class DebugScreenComposeTest {
onDatabaseLoggerEnabledChanged = {},
onEnableWireCellsFeature = {},
onShowFeatureFlags = {},
onFlushLogs = { CompletableDeferred(Unit) },
)
}
}
Expand Down
88 changes: 86 additions & 2 deletions app/src/main/kotlin/com/wire/android/WireApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import com.wire.android.feature.analytics.model.AnalyticsSettings
import com.wire.android.util.AppNameUtil
import com.wire.android.util.CurrentScreenManager
import com.wire.android.util.DataDogLogger
import com.wire.android.util.LogFileWriter
import com.wire.android.util.logging.LogFileWriter
import com.wire.android.util.getGitBuildId
import com.wire.android.util.lifecycle.SyncLifecycleManager
import com.wire.android.workmanager.WireWorkerFactory
Expand All @@ -59,8 +59,10 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import javax.inject.Inject

@Suppress("TooManyFunctions")
@HiltAndroidApp
class WireApplication : BaseApp() {

Expand Down Expand Up @@ -107,6 +109,8 @@ class WireApplication : BaseApp() {

enableStrictMode()

setupGlobalExceptionHandler()

startActivityLifecycleCallback()

globalAppScope.launch {
Expand Down Expand Up @@ -160,6 +164,82 @@ class WireApplication : BaseApp() {
}
}

private fun setupGlobalExceptionHandler() {
setupUncaughtExceptionHandler()
setupHistoricalExitMonitoring()
}

@Suppress("TooGenericExceptionCaught")
private fun setupUncaughtExceptionHandler() {
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, exception ->
flushLogsBeforeCrash()
defaultHandler?.uncaughtException(thread, exception)
}
}

@Suppress("TooGenericExceptionCaught")
private fun flushLogsBeforeCrash() {
// Use fire-and-forget approach to avoid blocking the crash handler
// which could lead to ANRs. We attempt a quick flush but don't wait for it.
try {
globalAppScope.launch(Dispatchers.IO) {
try {
// Use a very short timeout to avoid delaying the crash
withTimeout(CRASH_FLUSH_TIMEOUT_MS) {
logFileWriter.get().forceFlush()
}
appLogger.i("Logs flushed before crash")
} catch (e: Exception) {
// Log errors but don't block the crash handler
appLogger.e("Failed to flush logs before crash", e)
}
}
} catch (e: Exception) {
// Ignore any launch failures - we don't want to interfere with crash handling
}
}

@Suppress("TooGenericExceptionCaught")
private fun setupHistoricalExitMonitoring() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try {
val activityManager = getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager
activityManager.setProcessStateSummary(ByteArray(0))

// This will be called after the app exits, so we can't flush here,
// but we log it for diagnostics
globalAppScope.launch {
activityManager.getHistoricalProcessExitReasons(packageName, 0, MAX_HISTORICAL_EXIT_REASONS)
.forEach { info ->
logPreviousExitReason(info)
}
}
} catch (e: Exception) {
appLogger.e("Failed to setup app exit monitoring", e)
}
}
}

private fun logPreviousExitReason(info: android.app.ApplicationExitInfo) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
when (info.reason) {
android.app.ApplicationExitInfo.REASON_ANR -> {
appLogger.w("Previous app exit was due to ANR at ${info.timestamp}")
}
android.app.ApplicationExitInfo.REASON_CRASH -> {
appLogger.w("Previous app exit was due to crash at ${info.timestamp}")
}
android.app.ApplicationExitInfo.REASON_LOW_MEMORY -> {
appLogger.w("Previous app exit was due to low memory at ${info.timestamp}")
}
else -> {
appLogger.i("Previous app exit reason: ${info.reason} at ${info.timestamp}")
}
}
}
}

@Suppress("EmptyFunctionBlock")
private fun startActivityLifecycleCallback() {
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
Expand Down Expand Up @@ -290,7 +370,9 @@ class WireApplication : BaseApp() {
override fun onLowMemory() {
super.onLowMemory()
appLogger.w("onLowMemory called - Stopping logging, buckling the seatbelt and hoping for the best!")
logFileWriter.get().stop()
globalAppScope.launch {
logFileWriter.get().stop()
}
}

private companion object {
Expand All @@ -313,5 +395,7 @@ class WireApplication : BaseApp() {
}

private const val TAG = "WireApplication"
private const val CRASH_FLUSH_TIMEOUT_MS = 1000L
private const val MAX_HISTORICAL_EXIT_REASONS = 5
}
}
14 changes: 11 additions & 3 deletions app/src/main/kotlin/com/wire/android/di/LogWriterModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
package com.wire.android.di

import android.content.Context
import com.wire.android.util.LogFileWriter
import com.wire.android.BuildConfig
import com.wire.android.util.logging.LogFileWriterV1Impl
import com.wire.android.util.logging.LogFileWriter
import com.wire.android.util.logging.LogFileWriterV2Impl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
Expand All @@ -34,7 +37,12 @@ class LogWriterModule {
@Singleton
@Provides
fun provideKaliumFileWriter(@ApplicationContext context: Context): LogFileWriter {
val logsDirectory = LogFileWriter.logsDirectory(context)
return LogFileWriter(logsDirectory)
if (BuildConfig.USE_ASYNC_FLUSH_LOGGING) {
val logsDirectory = LogFileWriter.logsDirectory(context)
return LogFileWriterV2Impl(logsDirectory)
} else {
val logsDirectory = LogFileWriter.logsDirectory(context)
return LogFileWriterV1Impl(logsDirectory)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ import com.ramcosta.composedestinations.spec.Direction
import com.wire.android.BuildConfig
import com.wire.android.R
import com.wire.android.util.EmailComposer
import com.wire.android.util.LogFileWriter
import com.wire.android.util.getDeviceIdString
import com.wire.android.util.getGitBuildId
import com.wire.android.util.getUrisOfFilesInDirectory
import com.wire.android.util.logging.LogFileWriter
import com.wire.android.util.multipleFileSharingIntent
import com.wire.android.util.sha256

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import com.wire.android.util.CurrentScreen
import com.wire.android.util.CurrentScreenManager
import com.wire.android.util.dispatchers.DispatcherProvider
import com.wire.android.util.lifecycle.SyncLifecycleManager
import com.wire.android.util.logIfEmptyUserName
import com.wire.android.util.logging.logIfEmptyUserName
import com.wire.kalium.logger.obfuscateId
import com.wire.kalium.logic.CoreLogic
import com.wire.kalium.logic.data.id.ConversationId
Expand Down
45 changes: 30 additions & 15 deletions app/src/main/kotlin/com/wire/android/ui/debug/DebugScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipboardManager
Expand Down Expand Up @@ -61,6 +62,10 @@ import com.wire.android.util.AppNameUtil
import com.wire.android.util.getMimeType
import com.wire.android.util.getUrisOfFilesInDirectory
import com.wire.android.util.multipleFileSharingIntent
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.launch
import java.io.File

@WireDestination
Expand All @@ -74,6 +79,7 @@ fun DebugScreen(
state = userDebugViewModel.state,
onLoggingEnabledChange = userDebugViewModel::setLoggingEnabledState,
onDeleteLogs = userDebugViewModel::deleteLogs,
onFlushLogs = userDebugViewModel::flushLogs,
onDatabaseLoggerEnabledChanged = userDebugViewModel::setDatabaseLoggerEnabledState,
onEnableWireCellsFeature = userDebugViewModel::enableWireCellsFeature,
onShowFeatureFlags = {
Expand All @@ -89,6 +95,7 @@ internal fun UserDebugContent(
onLoggingEnabledChange: (Boolean) -> Unit,
onDatabaseLoggerEnabledChanged: (Boolean) -> Unit,
onDeleteLogs: () -> Unit,
onFlushLogs: () -> Deferred<Unit>,
onEnableWireCellsFeature: (Boolean) -> Unit,
onShowFeatureFlags: () -> Unit,
) {
Expand All @@ -115,7 +122,7 @@ internal fun UserDebugContent(
isLoggingEnabled = isLoggingEnabled,
onLoggingEnabledChange = onLoggingEnabledChange,
onDeleteLogs = onDeleteLogs,
onShareLogs = debugContentState::shareLogs,
onShareLogs = { debugContentState.shareLogs(onFlushLogs) },
isDBLoggerEnabled = state.isDBLoggingEnabled,
onDBLoggerEnabledChange = onDatabaseLoggerEnabledChanged,
isPrivateBuild = BuildConfig.PRIVATE_BUILD,
Expand Down Expand Up @@ -187,13 +194,15 @@ fun rememberDebugContentState(logPath: String): DebugContentState {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()

return remember {
DebugContentState(
context,
clipboardManager,
logPath,
scrollState
scrollState,
coroutineScope
)
}
}
Expand All @@ -202,7 +211,8 @@ data class DebugContentState(
val context: Context,
val clipboardManager: ClipboardManager,
val logPath: String,
val scrollState: ScrollState
val scrollState: ScrollState,
val coroutineScope: CoroutineScope
) {
fun copyToClipboard(text: String) {
clipboardManager.setText(AnnotatedString(text))
Expand All @@ -213,18 +223,22 @@ data class DebugContentState(
).show()
}

fun shareLogs() {
val dir = File(logPath).parentFile
val fileUris =
if (dir != null && dir.exists()) context.getUrisOfFilesInDirectory(dir) else arrayListOf()
val intent = context.multipleFileSharingIntent(fileUris)
// The first log file is simply text, not compressed. Get its mime type separately
// and set it as the mime type for the intent.
intent.type = fileUris.firstOrNull()?.getMimeType(context) ?: "text/plain"
// Get all other mime types and add them
val mimeTypes = fileUris.drop(1).mapNotNull { it.getMimeType(context) }
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes.toSet().toTypedArray())
context.startActivity(intent)
fun shareLogs(onFlushLogs: () -> Deferred<Unit>) {
coroutineScope.launch {
// Flush any buffered logs before sharing to ensure completeness
onFlushLogs().await()
val dir = File(logPath).parentFile
val fileUris =
if (dir != null && dir.exists()) context.getUrisOfFilesInDirectory(dir) else arrayListOf()
val intent = context.multipleFileSharingIntent(fileUris)
// The first log file is simply text, not compressed. Get its mime type separately
// and set it as the mime type for the intent.
intent.type = fileUris.firstOrNull()?.getMimeType(context) ?: "text/plain"
// Get all other mime types and add them
val mimeTypes = fileUris.drop(1).mapNotNull { it.getMimeType(context) }
intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes.toSet().toTypedArray())
context.startActivity(intent)
}
}
}

Expand All @@ -239,6 +253,7 @@ internal fun PreviewUserDebugContent() = WireTheme {
onNavigationPressed = {},
onLoggingEnabledChange = {},
onDeleteLogs = {},
onFlushLogs = { CompletableDeferred(Unit) },
onDatabaseLoggerEnabledChanged = {},
onEnableWireCellsFeature = {},
onShowFeatureFlags = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,16 @@ import androidx.lifecycle.viewModelScope
import com.wire.android.datastore.GlobalDataStore
import com.wire.android.di.CurrentAccount
import com.wire.android.util.EMPTY
import com.wire.android.util.LogFileWriter
import com.wire.android.util.logging.LogFileWriter
import com.wire.kalium.common.logger.CoreLogger
import com.wire.kalium.logger.KaliumLogLevel
import com.wire.kalium.logic.data.user.UserId
import com.wire.kalium.logic.feature.client.ObserveCurrentClientIdUseCase
import com.wire.kalium.logic.feature.debug.ChangeProfilingUseCase
import com.wire.kalium.logic.feature.debug.ObserveDatabaseLoggerStateUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import javax.inject.Inject

Expand Down Expand Up @@ -94,6 +96,12 @@ class UserDebugViewModel
logFileWriter.deleteAllLogFiles()
}

fun flushLogs(): Deferred<Unit> {
return viewModelScope.async {
logFileWriter.forceFlush()
}
}

fun setLoggingEnabledState(isEnabled: Boolean) {
viewModelScope.launch {
globalDataStore.setLoggingEnabled(isEnabled)
Expand Down
59 changes: 59 additions & 0 deletions app/src/main/kotlin/com/wire/android/util/logging/LogFileWriter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Wire
* Copyright (C) 2025 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/

package com.wire.android.util.logging

import android.content.Context
import java.io.File

/**
* Common interface for log file writers to enable easy substitution
* between different implementations.
*/
interface LogFileWriter {

/**
* The active logging file where logs are currently being written
*/
val activeLoggingFile: File

/**
* Starts the log collection system
*/
suspend fun start()

/**
* Stops the log collection system
*/
suspend fun stop()

/**
* Forces a flush of any pending logs to ensure they are written to file
*/
suspend fun forceFlush()

/**
* Deletes all log files including active and compressed files
*
*/
fun deleteAllLogFiles()

companion object {
fun logsDirectory(context: Context) = File(context.cacheDir, "logs")
}
}
Loading
Loading