Merge pull request #3453 from vitorpamplona/claude/quartz-logging-review-o1l51r

Make Log.sink pluggable for custom logging backends
This commit is contained in:
Vitor Pamplona
2026-07-02 09:33:52 -04:00
committed by GitHub
6 changed files with 208 additions and 13 deletions
@@ -42,7 +42,6 @@ class Lud06 {
null
}
} catch (t: Throwable) {
t.printStackTrace()
Log.w("Lud06ToLud16", "Fail to convert LUD06 to LUD16", t)
null
}
@@ -52,7 +51,6 @@ class Lud06 {
try {
Bech32.decodeBytes(str, false).second.decodeToString()
} catch (t: Throwable) {
t.printStackTrace()
Log.w("Lud06ToLud16", "Fail to convert LUD06 to LUD16", t)
null
}
@@ -277,7 +277,7 @@ class OpenTimestamps(
"Lite-client verification, assuming block $blockHash is valid",
)
} catch (e2: Exception) {
e2.printStackTrace()
// The stack trace propagates with the rethrown exception; the caller reports it.
throw e2
}
@@ -328,7 +328,7 @@ class OpenTimestamps(
subStamp.merge(upgradedStamp)
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
Log.w("OpenTimestamps", "Failed to merge upgraded timestamp", e)
}
upgraded = true
@@ -21,21 +21,31 @@
package com.vitorpamplona.quartz.utils
object Log {
/** Lines below this severity are dropped before ever reaching the [sink]. */
var minLevel: LogLevel = LogLevel.DEBUG
/**
* Where every line that passes [minLevel] is delivered. Replace this to hand
* logging over to the consuming application (Timber, SLF4J, a file, a test
* buffer, /dev/null). Defaults to [PlatformLogSink] — the historical
* per-platform native logger — so behavior is unchanged until a consumer
* opts in. See [LogSink].
*/
var sink: LogSink = PlatformLogSink
fun d(
tag: String,
message: String,
throwable: Throwable? = null,
) {
if (minLevel <= LogLevel.DEBUG) PlatformLog.d(tag, message, throwable)
if (minLevel <= LogLevel.DEBUG) sink.log(LogLevel.DEBUG, tag, message, throwable)
}
inline fun d(
tag: String,
message: () -> String,
) {
if (minLevel <= LogLevel.DEBUG) PlatformLog.d(tag, message())
if (minLevel <= LogLevel.DEBUG) sink.log(LogLevel.DEBUG, tag, message(), null)
}
fun i(
@@ -43,14 +53,14 @@ object Log {
message: String,
throwable: Throwable? = null,
) {
if (minLevel <= LogLevel.INFO) PlatformLog.i(tag, message, throwable)
if (minLevel <= LogLevel.INFO) sink.log(LogLevel.INFO, tag, message, throwable)
}
inline fun i(
tag: String,
message: () -> String,
) {
if (minLevel <= LogLevel.INFO) PlatformLog.i(tag, message())
if (minLevel <= LogLevel.INFO) sink.log(LogLevel.INFO, tag, message(), null)
}
fun w(
@@ -58,14 +68,14 @@ object Log {
message: String,
throwable: Throwable? = null,
) {
if (minLevel <= LogLevel.WARN) PlatformLog.w(tag, message, throwable)
if (minLevel <= LogLevel.WARN) sink.log(LogLevel.WARN, tag, message, throwable)
}
inline fun w(
tag: String,
message: () -> String,
) {
if (minLevel <= LogLevel.WARN) PlatformLog.w(tag, message())
if (minLevel <= LogLevel.WARN) sink.log(LogLevel.WARN, tag, message(), null)
}
fun e(
@@ -73,13 +83,13 @@ object Log {
message: String,
throwable: Throwable? = null,
) {
if (minLevel <= LogLevel.ERROR) PlatformLog.e(tag, message, throwable)
if (minLevel <= LogLevel.ERROR) sink.log(LogLevel.ERROR, tag, message, throwable)
}
inline fun e(
tag: String,
message: () -> String,
) {
if (minLevel <= LogLevel.ERROR) PlatformLog.e(tag, message())
if (minLevel <= LogLevel.ERROR) sink.log(LogLevel.ERROR, tag, message(), null)
}
}
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils
/**
* Destination for every diagnostic line Quartz emits past the [Log.minLevel] gate.
*
* Quartz owns *what* to log; the consumer of the library owns *where* it goes.
* Replace [Log.sink] to route Quartz's diagnostics into your own logging stack
* (Timber, SLF4J/Logback, Crashlytics, a file, a test buffer, or nowhere at all):
*
* ```kotlin
* Log.sink = LogSink { level, tag, message, throwable ->
* Timber.tag(tag).log(level.toAndroidPriority(), throwable, message)
* }
* ```
*
* The default is [PlatformLogSink], which reproduces the historical per-platform
* behavior (android.util.Log on Android, System.err on JVM, NSLog on Apple,
* println on Linux), so nothing changes unless a consumer opts in.
*
* The [message] has already passed the [Log.minLevel] filter and any lazy
* `() -> String` builder has already been evaluated, so a sink never needs to
* re-check the level for allocation reasons.
*/
fun interface LogSink {
fun log(
level: LogLevel,
tag: String,
message: String,
throwable: Throwable?,
)
}
/**
* Default [LogSink] that forwards to the platform's native logger through
* [PlatformLog]. Used unless a consumer installs their own [Log.sink].
*/
object PlatformLogSink : LogSink {
override fun log(
level: LogLevel,
tag: String,
message: String,
throwable: Throwable?,
) {
when (level) {
LogLevel.DEBUG -> PlatformLog.d(tag, message, throwable)
LogLevel.INFO -> PlatformLog.i(tag, message, throwable)
LogLevel.WARN -> PlatformLog.w(tag, message, throwable)
LogLevel.ERROR -> PlatformLog.e(tag, message, throwable)
}
}
}
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
class LogSinkTest {
private data class Line(
val level: LogLevel,
val tag: String,
val message: String,
val throwable: Throwable?,
)
private val originalSink = Log.sink
private val originalMinLevel = Log.minLevel
@AfterTest
fun restore() {
Log.sink = originalSink
Log.minLevel = originalMinLevel
}
@Test
fun defaultSinkIsThePlatformLogger() {
assertSame(PlatformLogSink, Log.sink)
}
@Test
fun consumerSinkReceivesEveryLevelWithTagMessageAndThrowable() {
val captured = mutableListOf<Line>()
Log.minLevel = LogLevel.DEBUG
Log.sink = LogSink { level, tag, message, throwable -> captured.add(Line(level, tag, message, throwable)) }
val boom = RuntimeException("boom")
Log.d("A", "d")
Log.i("B", "i")
Log.w("C", "w")
Log.e("D", "e", boom)
assertEquals(
listOf(
Line(LogLevel.DEBUG, "A", "d", null),
Line(LogLevel.INFO, "B", "i", null),
Line(LogLevel.WARN, "C", "w", null),
Line(LogLevel.ERROR, "D", "e", boom),
),
captured,
)
}
@Test
fun minLevelGatesBeforeReachingTheSink() {
val captured = mutableListOf<Line>()
Log.minLevel = LogLevel.WARN
Log.sink = LogSink { level, tag, message, throwable -> captured.add(Line(level, tag, message, throwable)) }
Log.d("A", "dropped")
Log.i("B", "dropped")
Log.w("C", "kept")
Log.e("D", "kept")
assertEquals(listOf(LogLevel.WARN, LogLevel.ERROR), captured.map { it.level })
}
@Test
fun lazyMessageIsNotBuiltWhenGatedOut() {
var built = false
Log.minLevel = LogLevel.ERROR
Log.sink = LogSink { _, _, _, _ -> }
Log.d("A") {
built = true
"expensive"
}
assertTrue(!built, "lambda must not run below minLevel")
}
@Test
fun lazyMessagePassesNullThrowable() {
var captured: Line? = null
Log.minLevel = LogLevel.DEBUG
Log.sink = LogSink { level, tag, message, throwable -> captured = Line(level, tag, message, throwable) }
Log.w("T") { "built" }
assertEquals(LogLevel.WARN, captured?.level)
assertEquals("built", captured?.message)
assertNull(captured?.throwable)
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -116,7 +117,7 @@ class ElectrumXClient(
throw e
} catch (e: Exception) {
// Log but don't crash — callers handle null gracefully.
e.printStackTrace()
Log.w("ElectrumXClient", "Failed to look up $identifier on ${server.host}:${server.port}", e)
null
}
}