diff --git a/README.md b/README.md index 4e1b2284..a7c103ce 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ Logging can be initialized via [`install`]: Log.dispatcher.install(ConsoleLogger) ``` +If no [`Logger`] is installed, then log blocks are not called at runtime. + Custom loggers can be created by implementing the [`Logger`] interface. #### Android (Logcat) @@ -134,6 +136,46 @@ enum class Sample { } ``` +### Filtering + +Tuulbox implements log filtering by decorating [`Logger`]s. + +#### Log Level Filters + +Log level filters are installed with [`Logger.withMinimumLogLevel`]. Because the filtering is based on which log call is +made, instead of the content of the log call, these can be used as an optimization: if all [`Logger`]s installed in the +root [`DispatchLogger`] have a minimum log level higher than the log call being made, then the log block is never called. + +```kotlin +Log.dispatcher.install( + ConsoleLogger + .withMinimumLogLevel(LogLevel.Warn) +) + +Log.debug { "This is not called." } +Log.warn { "This still gets called." } +``` + +#### Log Content Filters + +Log content filters are installed with [`Logger.withFilter`], and have full access to the content of a log. + + +```kotlin +Log.dispatcher.install( + ConsoleLogger + .withFilter { tag, message, metadata, throwable -> + metadata[Sensitivity] == Sensitivity.NotSensitive + } +) + +Log.debug { "This block is evaluated, but does not get printed to the console." } +Log.warn { metadata -> + metadata[Sensitivity] = Sensitivity.NotSensitive + "This is also evaluated, and does print to the console." +} +``` + ## [Functional](https://juullabs.github.io/tuulbox/functional/index.html) ![badge-ios] @@ -361,6 +403,9 @@ limitations under the License. [`WriteMetadata`]: https://juullabs.github.io/tuulbox/logging/logging/com.juul.tuulbox.logging/-write-metadata/index.html [`ReadMetadata`]: https://juullabs.github.io/tuulbox/logging/logging/com.juul.tuulbox.logging/-read-metadata/index.html [`Key`]: https://juullabs.github.io/tuulbox/logging/logging/com.juul.tuulbox.logging/-key/index.html +[`Logger.withMinimumLogLevel`]: https://juullabs.github.io/tuulbox/logging/logging/com.juul.tuulbox.logging/with-minimum-log-level.html +[`DispatchLogger`]: https://juullabs.github.io/tuulbox/logging/logging/com.juul.tuulbox.logging/-dispatch-logger/index.html +[`Logger.withFilter`]: https://juullabs.github.io/tuulbox/logging/logging/com.juul.tuulbox.logging/with-filter.html [`runTest`]: https://juullabs.github.io/tuulbox/test/test/com.juul.tuulbox.test/run-test.html [`assertContains`]: https://juullabs.github.io/tuulbox/test/test/com.juul.tuulbox.test/assert-contains.html [`assertSimilar`]: https://juullabs.github.io/tuulbox/test/test/com.juul.tuulbox.test/assert-similar.html diff --git a/logging/src/commonMain/kotlin/FilterLogger.kt b/logging/src/commonMain/kotlin/FilterLogger.kt new file mode 100644 index 00000000..05c422d6 --- /dev/null +++ b/logging/src/commonMain/kotlin/FilterLogger.kt @@ -0,0 +1,54 @@ +package com.juul.tuulbox.logging + +public fun interface LogFilter { + public fun canLog(tag: String, message: String, metadata: ReadMetadata, throwable: Throwable?): Boolean +} + +public fun Logger.withFilter( + filter: LogFilter +): Logger = FilterLogger(filter, this) + +private class FilterLogger( + private val filter: LogFilter, + private val inner: Logger, +) : Logger { + + override val minimumLogLevel: LogLevel + get() = inner.minimumLogLevel + + override fun verbose(tag: String, message: String, metadata: ReadMetadata, throwable: Throwable?) { + if (filter.canLog(tag, message, metadata, throwable)) { + inner.verbose(tag, message, metadata, throwable) + } + } + + override fun debug(tag: String, message: String, metadata: ReadMetadata, throwable: Throwable?) { + if (filter.canLog(tag, message, metadata, throwable)) { + inner.debug(tag, message, metadata, throwable) + } + } + + override fun info(tag: String, message: String, metadata: ReadMetadata, throwable: Throwable?) { + if (filter.canLog(tag, message, metadata, throwable)) { + inner.info(tag, message, metadata, throwable) + } + } + + override fun warn(tag: String, message: String, metadata: ReadMetadata, throwable: Throwable?) { + if (filter.canLog(tag, message, metadata, throwable)) { + inner.warn(tag, message, metadata, throwable) + } + } + + override fun error(tag: String, message: String, metadata: ReadMetadata, throwable: Throwable?) { + if (filter.canLog(tag, message, metadata, throwable)) { + inner.error(tag, message, metadata, throwable) + } + } + + override fun assert(tag: String, message: String, metadata: ReadMetadata, throwable: Throwable?) { + if (filter.canLog(tag, message, metadata, throwable)) { + inner.assert(tag, message, metadata, throwable) + } + } +} diff --git a/logging/src/commonTest/kotlin/FilterLoggerTests.kt b/logging/src/commonTest/kotlin/FilterLoggerTests.kt new file mode 100644 index 00000000..75c71763 --- /dev/null +++ b/logging/src/commonTest/kotlin/FilterLoggerTests.kt @@ -0,0 +1,141 @@ +package com.juul.tuulbox.logging + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class FilterLoggerTests { + + @Test + fun verbose_whenDenied_doesNotCallInner() { + val inner = CallListLogger() + val throwable = Throwable() + inner.withFilter { _, _, _, _ -> false } + .verbose("tag", "message", Metadata(), throwable) + assertTrue(inner.verboseCalls.isEmpty()) + } + + @Test + fun verbose_whenPermitted_callsInnerWithSameArguments() { + val inner = CallListLogger() + val throwable = Throwable() + inner.withFilter { _, _, _, _ -> true } + .verbose("tag", "message", Metadata(), throwable) + val call = inner.verboseCalls.single() + assertEquals("tag", call.tag) + assertEquals("message", call.message) + assertEquals(Metadata(), call.metadata) + assertSame(throwable, call.throwable) + } + + @Test + fun debug_whenDenied_doesNotCallInner() { + val inner = CallListLogger() + val throwable = Throwable() + inner.withFilter { _, _, _, _ -> false } + .debug("tag", "message", Metadata(), throwable) + assertTrue(inner.debugCalls.isEmpty()) + } + + @Test + fun debug_whenPermitted_callsInnerWithSameArguments() { + val inner = CallListLogger() + val throwable = Throwable() + inner.withFilter { _, _, _, _ -> true } + .debug("tag", "message", Metadata(), throwable) + val call = inner.debugCalls.single() + assertEquals("tag", call.tag) + assertEquals("message", call.message) + assertEquals(Metadata(), call.metadata) + assertSame(throwable, call.throwable) + } + + @Test + fun info_whenDenied_doesNotCallInner() { + val inner = CallListLogger() + val throwable = Throwable() + inner.withFilter { _, _, _, _ -> false } + .info("tag", "message", Metadata(), throwable) + assertTrue(inner.infoCalls.isEmpty()) + } + + @Test + fun info_whenPermitted_callsInnerWithSameArguments() { + val inner = CallListLogger() + val throwable = Throwable() + inner.withFilter { _, _, _, _ -> true } + .info("tag", "message", Metadata(), throwable) + val call = inner.infoCalls.single() + assertEquals("tag", call.tag) + assertEquals("message", call.message) + assertEquals(Metadata(), call.metadata) + assertSame(throwable, call.throwable) + } + + @Test + fun warn_whenDenied_doesNotCallInner() { + val inner = CallListLogger() + val throwable = Throwable() + inner.withFilter { _, _, _, _ -> false } + .warn("tag", "message", Metadata(), throwable) + assertTrue(inner.warnCalls.isEmpty()) + } + + @Test + fun warn_whenPermitted_callsInnerWithSameArguments() { + val inner = CallListLogger() + val throwable = Throwable() + inner.withFilter { _, _, _, _ -> true } + .warn("tag", "message", Metadata(), throwable) + val call = inner.warnCalls.single() + assertEquals("tag", call.tag) + assertEquals("message", call.message) + assertEquals(Metadata(), call.metadata) + assertSame(throwable, call.throwable) + } + + @Test + fun error_whenDenied_doesNotCallInner() { + val inner = CallListLogger() + val throwable = Throwable() + inner.withFilter { _, _, _, _ -> false } + .error("tag", "message", Metadata(), throwable) + assertTrue(inner.errorCalls.isEmpty()) + } + + @Test + fun error_whenPermitted_callsInnerWithSameArguments() { + val inner = CallListLogger() + val throwable = Throwable() + inner.withFilter { _, _, _, _ -> true } + .error("tag", "message", Metadata(), throwable) + val call = inner.errorCalls.single() + assertEquals("tag", call.tag) + assertEquals("message", call.message) + assertEquals(Metadata(), call.metadata) + assertSame(throwable, call.throwable) + } + + @Test + fun assert_whenDenied_doesNotCallInner() { + val inner = CallListLogger() + val throwable = Throwable() + inner.withFilter { _, _, _, _ -> false } + .assert("tag", "message", Metadata(), throwable) + assertTrue(inner.assertCalls.isEmpty()) + } + + @Test + fun assert_whenPermitted_callsInnerWithSameArguments() { + val inner = CallListLogger() + val throwable = Throwable() + inner.withFilter { _, _, _, _ -> true } + .assert("tag", "message", Metadata(), throwable) + val call = inner.assertCalls.single() + assertEquals("tag", call.tag) + assertEquals("message", call.message) + assertEquals(Metadata(), call.metadata) + assertSame(throwable, call.throwable) + } +}