logging: make the writer total and harden export

Never let a log-write or export failure reach the uncaught handler: wrap the async writer and crash path, synchronize export with rotation, and tolerate vanished files. Redact the Logcat stack-trace copy, stop leaking rows=0 on writes, shut down the previous writer on re-init, and drop unused API. Strengthen tests for the batched write path, rotation count and throwable redaction.
This commit is contained in:
austinried
2026-10-09 07:58:55 +00:00
parent 7a890094fc
commit 8fe26a36b1
7 changed files with 102 additions and 43 deletions
@@ -6,6 +6,7 @@ import androidx.sqlite.SQLiteConnection
import androidx.sqlite.SQLiteDriver
import androidx.sqlite.SQLiteStatement
import com.subtracks.log.Log
import kotlinx.coroutines.CancellationException
data class SqlExecution(
val sql: String,
@@ -102,6 +103,8 @@ class LoggedMigration(
val start = System.nanoTime()
try {
delegate.migrate(connection)
} catch (cancellation: CancellationException) {
throw cancellation
} catch (failure: Throwable) {
Log.e("sql", "migration $label failed after ${(System.nanoTime() - start) / 1_000_000}ms", failure)
throw failure
+12 -9
View File
@@ -28,16 +28,12 @@ object Log {
appContext = app
consoleLevel = if (BuildConfig.DEBUG) LogLevel.VERBOSE else LogLevel.INFO
val fileLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO
fileStore?.shutdown()
fileStore = LogFileStore(File(app.filesDir, "logs"), fileLevel)
installCrashHandler()
i("app", "started ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) on Android ${Build.VERSION.SDK_INT} ${Build.MODEL}")
}
fun v(
tag: String,
message: String,
) = log(LogLevel.VERBOSE, tag, message, null)
fun d(
tag: String,
message: String,
@@ -60,8 +56,6 @@ object Log {
throwable: Throwable? = null,
) = log(LogLevel.ERROR, tag, message, throwable)
fun files(): List<File> = fileStore?.files().orEmpty()
fun exportZip(): File? {
val store = fileStore ?: return null
val context = appContext ?: return null
@@ -109,7 +103,8 @@ object Log {
return
}
if (!isSlowQuery(execution, SLOW_QUERY_MS)) return
w("sql", "slow ${execution.durationMillis.roundToLong()}ms rows=${execution.rows}: ${shortSql(execution.sql)}")
val rows = if (isReadStatement(execution.sql)) " rows=${execution.rows}" else ""
w("sql", "slow ${execution.durationMillis.roundToLong()}ms$rows: ${shortSql(execution.sql)}")
}
private fun log(
@@ -120,7 +115,12 @@ object Log {
) {
val redacted = redactLogs(message)
if (level.ordinal >= consoleLevel.ordinal) {
val text = if (throwable != null) "$redacted\n${AndroidLog.getStackTraceString(throwable)}" else redacted
val text =
if (throwable != null) {
"$redacted\n${redactLogs(AndroidLog.getStackTraceString(throwable))}"
} else {
redacted
}
AndroidLog.println(priority(level), tag.take(23), text)
}
fileStore?.write(level, tag, redacted, throwable)
@@ -151,6 +151,7 @@ object Log {
}
private val DATA_KEYWORDS = setOf("SELECT", "INSERT", "UPDATE", "DELETE", "WITH", "REPLACE")
private val READ_KEYWORDS = setOf("SELECT", "WITH")
private const val SQL_INTERNAL = "room_table_modification_log"
internal fun isDataStatement(sql: String): Boolean {
@@ -159,6 +160,8 @@ internal fun isDataStatement(sql: String): Boolean {
return normalized.takeWhile { !it.isWhitespace() }.uppercase() in DATA_KEYWORDS
}
internal fun isReadStatement(sql: String): Boolean = sql.trimStart().takeWhile { !it.isWhitespace() }.uppercase() in READ_KEYWORDS
internal fun isSlowQuery(
execution: SqlExecution,
thresholdMs: Double,
@@ -10,15 +10,6 @@ import java.util.zip.ZipOutputStream
enum class LogLevel { VERBOSE, DEBUG, INFO, WARN, ERROR }
fun interface LogSink {
fun log(
level: LogLevel,
tag: String,
message: String,
throwable: Throwable?,
)
}
class LogFileStore(
private val rootDir: File,
private val minLevel: LogLevel,
@@ -31,7 +22,6 @@ class LogFileStore(
}
private val lock = Any()
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private val stampFormat = SimpleDateFormat("yyyy-MM-dd-HHmmss-SSS", Locale.US)
private val lineFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
private var current = currentFile()
@@ -42,7 +32,9 @@ class LogFileStore(
throwable: Throwable?,
) {
if (level.ordinal < minLevel.ordinal) return
executor.execute { synchronized(lock) { append(level, tag, message, throwable) } }
runCatching {
executor.execute { runCatching { synchronized(lock) { append(level, tag, message, throwable) } } }
}
}
fun writeImmediately(
@@ -52,11 +44,15 @@ class LogFileStore(
throwable: Throwable?,
) {
if (level.ordinal < minLevel.ordinal) return
synchronized(lock) { append(level, tag, message, throwable) }
runCatching { synchronized(lock) { append(level, tag, message, throwable) } }
}
fun flush() {
executor.submit {}.get()
runCatching { executor.submit {}.get() }
}
fun shutdown() {
executor.shutdownNow()
}
fun files(): List<File> =
@@ -65,20 +61,22 @@ class LogFileStore(
?.sortedByDescending { it.lastModified() }
.orEmpty()
fun exportZip(destDir: File): File? {
val logs = files()
if (logs.isEmpty()) return null
destDir.mkdirs()
val zip = File(destDir, "subtracks-logs-${stampFormat.format(Date())}.zip")
ZipOutputStream(zip.outputStream().buffered()).use { out ->
logs.forEach { log ->
out.putNextEntry(ZipEntry(log.name))
log.inputStream().use { it.copyTo(out) }
out.closeEntry()
fun exportZip(destDir: File): File? =
synchronized(lock) {
val logs = files()
if (logs.isEmpty()) return null
destDir.mkdirs()
val zip = File(destDir, "subtracks-logs-${System.currentTimeMillis()}.zip")
ZipOutputStream(zip.outputStream().buffered()).use { out ->
logs.forEach { log ->
if (!log.isFile) return@forEach
out.putNextEntry(ZipEntry(log.name))
log.inputStream().use { it.copyTo(out) }
out.closeEntry()
}
}
zip
}
return zip
}
private fun append(
level: LogLevel,
@@ -129,12 +129,19 @@ fun SettingsRoute(
onOpenLicenses = onOpenLicenses,
onShareLogs = {
scope.launch {
val zip = withContext(Dispatchers.IO) { Log.exportZip() }
if (zip != null) {
Log.share(context, zip)
} else {
Toast.makeText(context, R.string.settings_about_share_logs_empty, Toast.LENGTH_SHORT).show()
}
val result =
runCatching {
val zip = withContext(Dispatchers.IO) { Log.exportZip() }
if (zip != null) Log.share(context, zip)
zip
}
val message =
when {
result.isFailure -> R.string.settings_about_share_logs_failed
result.getOrNull() == null -> R.string.settings_about_share_logs_empty
else -> null
}
message?.let { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() }
}
},
onBack = onBack,