From 292473ee26aac76e9e46a51f2392ff32c915124a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 13:25:59 +0000 Subject: [PATCH 1/2] feat(quartz): let consumers own logging via a swappable LogSink Quartz already funnels every diagnostic through the `Log` facade, but the sink was hardcoded per platform (android.util.Log / System.err / NSLog / println), so a consuming app couldn't route Quartz logs into its own stack (Timber, SLF4J, Crashlytics, a file, a test buffer, or /dev/null). Add a `LogSink` fun interface and a replaceable `Log.sink`, defaulting to `PlatformLogSink` which reproduces the historical per-platform behavior. All ~225 call sites and the `Log.*` signatures are unchanged; the lazy `() -> String` overloads still short-circuit on `minLevel` before the lambda runs, preserving the allocation-free fast path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EK3TrDkP1EXj1d62oKJMdc --- .../com/vitorpamplona/quartz/utils/Log.kt | 26 ++-- .../com/vitorpamplona/quartz/utils/LogSink.kt | 71 +++++++++++ .../vitorpamplona/quartz/utils/LogSinkTest.kt | 115 ++++++++++++++++++ 3 files changed, 204 insertions(+), 8 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/LogSink.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/LogSinkTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Log.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Log.kt index 214148946a..7fbc0108da 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Log.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Log.kt @@ -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) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/LogSink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/LogSink.kt new file mode 100644 index 0000000000..2bbad9b367 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/LogSink.kt @@ -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) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/LogSinkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/LogSinkTest.kt new file mode 100644 index 0000000000..ff0c26d40b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/LogSinkTest.kt @@ -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() + 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() + 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) + } +} From 167fe96345ad2651e1296c5a5da536eefa198057 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 13:31:47 +0000 Subject: [PATCH 2/2] refactor(quartz): route stray printStackTrace through the Log facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit printStackTrace() dumps straight to stderr, bypassing both Log.minLevel and the consumer's Log.sink — the very thing the LogSink work exists to control. Migrate the five production call sites: - Lud06: drop two printStackTrace() calls that sat directly above an existing Log.w(..., t) carrying the same throwable (pure duplication). - ElectrumXClient: the swallowed-lookup catch said "Log but don't crash" yet used printStackTrace(); route it through Log.w with context. - OpenTimestamps: log the swallowed merge failure via Log.w; drop the print-then-rethrow (the rethrown exception already carries the trace). Socket-protocol writer.println(...) and README/KDoc println examples are left as-is — they are wire I/O and documentation, not logging. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EK3TrDkP1EXj1d62oKJMdc --- .../kotlin/com/vitorpamplona/quartz/lightning/Lud06.kt | 2 -- .../vitorpamplona/quartz/nip03Timestamp/ots/OpenTimestamps.kt | 4 ++-- .../quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt | 3 ++- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/lightning/Lud06.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/lightning/Lud06.kt index 75532d6247..234e472e00 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/lightning/Lud06.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/lightning/Lud06.kt @@ -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 } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OpenTimestamps.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OpenTimestamps.kt index ce326cf837..0c9dbba5cc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OpenTimestamps.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/OpenTimestamps.kt @@ -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 diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index 721a9a48e2..0fc46a1bf0 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -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 } }