Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-30 15:29:56 +04:00
parent 91c2fb2d58
commit d718cf557d
18 changed files with 1135 additions and 280 deletions

View file

@ -23,10 +23,6 @@ dependencies {
implementation(deps.jodatime)
// endregion
// region Logging
implementation(deps.kermit)
// endregion
testImplementation(deps.test.coroutine)
testImplementation(deps.test.junit5)
testRuntimeOnly(deps.test.junit5.engine)

View file

@ -0,0 +1,13 @@
package com.tangem.utils.logging
/**
* Common contract for application loggers.
*/
internal interface BaseLogger {
fun v(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true)
fun d(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true)
fun i(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true)
fun w(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true)
fun e(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true)
fun a(messageString: String, throwable: Throwable? = null, shouldSanitize: Boolean = true)
}

View file

@ -0,0 +1,35 @@
package com.tangem.utils.logging
import java.util.regex.Pattern
/**
* Resolves a log tag from the call site's class name when the caller didn't supply one
* via [TangemLogger.withTag]. Used by [TangemLogger.write] before dispatching to writers.
*/
internal object LogTagResolver {
private const val FALLBACK_TAG = "TangemAppLogger"
private val ANONYMOUS_CLASS_REGEX: Pattern = Pattern.compile("(\\$\\d+)+$")
private val FQCN_IGNORE = setOf(
LogTagResolver::class.java.name,
TangemLogger::class.java.name,
TangemLogger.TaggedLogger::class.java.name,
// Synthetic class generated for BaseLogger's default-arg trampolines (d$default, etc.).
// Without this, every call that omits default args resolves to BaseLogger.DefaultImpls.
"${BaseLogger::class.java.name}\$DefaultImpls",
)
@Suppress("ThrowingExceptionsWithoutMessageOrCause")
fun resolveTag(): String {
val element = Throwable().stackTrace.firstOrNull { it.className !in FQCN_IGNORE }
?: return FALLBACK_TAG
var tag = element.className.substringAfterLast('.')
val matcher = ANONYMOUS_CLASS_REGEX.matcher(tag)
if (matcher.find()) {
tag = matcher.replaceAll("")
}
return tag
}
}

View file

@ -0,0 +1,10 @@
package com.tangem.utils.logging
enum class Severity {
Verbose,
Debug,
Info,
Warn,
Error,
Assert,
}

View file

@ -1,63 +1,173 @@
package com.tangem.utils.logging
import co.touchlab.kermit.Logger
import java.util.concurrent.CopyOnWriteArrayList
/**
* Application-level logger that wraps Kermit [Logger] with the same API.
* All modules should use [TangemLogger] instead of importing Kermit directly.
* Application-level logger
*/
object TangemLogger {
object TangemLogger : BaseLogger {
fun v(messageString: String, throwable: Throwable? = null) {
Logger.v(messageString, throwable)
private val logWriters = CopyOnWriteArrayList<LogWriter>()
fun setLogWriters(writers: List<LogWriter>) {
logWriters.clear()
logWriters.addAll(writers)
}
fun d(messageString: String, throwable: Throwable? = null) {
Logger.d(messageString, throwable)
fun addLogWriter(writer: LogWriter) {
logWriters.add(writer)
}
fun i(messageString: String, throwable: Throwable? = null) {
Logger.i(messageString, throwable)
override fun v(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Verbose,
tag = null,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
fun w(messageString: String, throwable: Throwable? = null) {
Logger.w(messageString, throwable)
override fun d(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Debug,
tag = null,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
fun e(messageString: String, throwable: Throwable? = null) {
Logger.e(messageString, throwable)
override fun i(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Info,
tag = null,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
fun a(messageString: String, throwable: Throwable? = null) {
Logger.a(messageString, throwable)
override fun w(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Warn,
tag = null,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
override fun e(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Error,
tag = null,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
override fun a(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Assert,
tag = null,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
fun withTag(tag: String): TaggedLogger = TaggedLogger(tag)
class TaggedLogger internal constructor(private val tag: String) {
fun v(messageString: String, throwable: Throwable? = null) {
Logger.withTag(tag).v(messageString, throwable)
}
fun d(messageString: String, throwable: Throwable? = null) {
Logger.withTag(tag).d(messageString, throwable)
}
fun i(messageString: String, throwable: Throwable? = null) {
Logger.withTag(tag).i(messageString, throwable)
}
fun w(messageString: String, throwable: Throwable? = null) {
Logger.withTag(tag).w(messageString, throwable)
}
fun e(messageString: String, throwable: Throwable? = null) {
Logger.withTag(tag).e(messageString, throwable)
}
fun a(messageString: String, throwable: Throwable? = null) {
Logger.withTag(tag).a(messageString, throwable)
private fun write(
severity: Severity,
tag: String?,
message: String,
throwable: Throwable?,
shouldSanitize: Boolean,
) {
val resolvedTag = tag ?: LogTagResolver.resolveTag()
logWriters.forEach { writer ->
if (writer.isLoggable(severity, resolvedTag)) {
writer.write(
severity = severity,
tag = resolvedTag,
message = message,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
}
}
class TaggedLogger internal constructor(private val tag: String) : BaseLogger {
override fun v(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Verbose,
tag = tag,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
override fun d(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Debug,
tag = tag,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
override fun i(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Info,
tag = tag,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
override fun w(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Warn,
tag = tag,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
override fun e(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Error,
tag = tag,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
override fun a(messageString: String, throwable: Throwable?, shouldSanitize: Boolean) {
write(
severity = Severity.Assert,
tag = tag,
message = messageString,
throwable = throwable,
shouldSanitize = shouldSanitize,
)
}
}
interface LogWriter {
fun isLoggable(severity: Severity, tag: String): Boolean = true
fun write(severity: Severity, tag: String, message: String, throwable: Throwable?, shouldSanitize: Boolean)
}
}

View file

@ -0,0 +1,100 @@
package com.tangem.utils.logging
import com.google.common.truth.Truth
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class LogTagResolverTest {
@AfterEach
fun tearDown() {
// Clean up shared TangemLogger state used in some cases
TangemLogger.setLogWriters(emptyList())
}
@Test
fun `resolveTag returns the simple class name of the direct caller`() {
// Act
val tag = LogTagResolver.resolveTag()
// Assert
Truth.assertThat(tag).isEqualTo("LogTagResolverTest")
}
@Test
fun `resolveTag does not include package qualifier`() {
// Act
val tag = LogTagResolver.resolveTag()
// Assert
Truth.assertThat(tag).doesNotContain(".")
}
@Test
fun `resolveTag never returns its own class name`() {
// Act
val tag = LogTagResolver.resolveTag()
// Assert
Truth.assertThat(tag).isNotEqualTo("LogTagResolver")
}
@Test
fun `resolveTag skips TangemLogger frames when invoked through it`() {
// Arrange
var capturedTag: String? = null
val writer = object : TangemLogger.LogWriter {
override fun write(
severity: Severity,
tag: String,
message: String,
throwable: Throwable?,
shouldSanitize: Boolean,
) {
capturedTag = tag
}
}
TangemLogger.setLogWriters(listOf(writer))
// Act
TangemLogger.d("via TangemLogger")
// Assert — TangemLogger and LogTagResolver are filtered, leaving the test class
Truth.assertThat(capturedTag).isEqualTo("LogTagResolverTest")
}
@Test
fun `resolveTag is bypassed by TaggedLogger when an explicit tag is supplied`() {
// Arrange
var capturedTag: String? = null
val writer = object : TangemLogger.LogWriter {
override fun write(
severity: Severity,
tag: String,
message: String,
throwable: Throwable?,
shouldSanitize: Boolean,
) {
capturedTag = tag
}
}
TangemLogger.setLogWriters(listOf(writer))
// Act
TangemLogger.withTag("ExplicitTag").d("hi")
// Assert
Truth.assertThat(capturedTag).isEqualTo("ExplicitTag")
}
@Test
fun `resolveTag returns a non-empty string`() {
// Act
val tag = LogTagResolver.resolveTag()
// Assert
Truth.assertThat(tag).isNotEmpty()
}
}

View file

@ -0,0 +1,311 @@
package com.tangem.utils.logging
import com.google.common.truth.Truth
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifyOrder
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TangemLoggerTest {
private lateinit var writer: TangemLogger.LogWriter
@BeforeEach
fun setUp() {
writer = mockk(relaxed = true)
every { writer.isLoggable(any(), any()) } returns true
TangemLogger.setLogWriters(listOf(writer))
}
@AfterEach
fun tearDown() {
// Reset singleton state to avoid cross-test pollution
TangemLogger.setLogWriters(emptyList())
}
// region Severity dispatch
@Test
fun `v dispatches Verbose severity to writer`() {
// Act
TangemLogger.v("verbose message")
// Assert
verify(exactly = 1) {
writer.write(Severity.Verbose, any(), "verbose message", null, true)
}
}
@Test
fun `d dispatches Debug severity to writer`() {
// Act
TangemLogger.d("debug message")
// Assert
verify(exactly = 1) {
writer.write(Severity.Debug, any(), "debug message", null, true)
}
}
@Test
fun `i dispatches Info severity to writer`() {
// Act
TangemLogger.i("info message")
// Assert
verify(exactly = 1) {
writer.write(Severity.Info, any(), "info message", null, true)
}
}
@Test
fun `w dispatches Warn severity to writer`() {
// Act
TangemLogger.w("warn message")
// Assert
verify(exactly = 1) {
writer.write(Severity.Warn, any(), "warn message", null, true)
}
}
@Test
fun `e dispatches Error severity to writer`() {
// Act
TangemLogger.e("error message")
// Assert
verify(exactly = 1) {
writer.write(Severity.Error, any(), "error message", null, true)
}
}
@Test
fun `a dispatches Assert severity to writer`() {
// Act
TangemLogger.a("assert message")
// Assert
verify(exactly = 1) {
writer.write(Severity.Assert, any(), "assert message", null, true)
}
}
// endregion
// region Throwable & shouldSanitize propagation
@Test
fun `throwable parameter is forwarded to writer`() {
// Arrange
val throwable = IllegalStateException("boom")
// Act
TangemLogger.e("error", throwable)
// Assert
verify(exactly = 1) {
writer.write(Severity.Error, any(), "error", throwable, true)
}
}
@Test
fun `shouldSanitize flag is forwarded to writer`() {
// Act
TangemLogger.w("not checked", shouldSanitize = false)
// Assert
verify(exactly = 1) {
writer.write(Severity.Warn, any(), "not checked", null, false)
}
}
// endregion
// region setLogWriters / addLogWriter
@Test
fun `setLogWriters replaces previously registered writers`() {
// Arrange
val previous: TangemLogger.LogWriter = mockk(relaxed = true)
every { previous.isLoggable(any(), any()) } returns true
val replacement: TangemLogger.LogWriter = mockk(relaxed = true)
every { replacement.isLoggable(any(), any()) } returns true
TangemLogger.setLogWriters(listOf(previous))
TangemLogger.setLogWriters(listOf(replacement))
// Act
TangemLogger.i("after replace")
// Assert
verify(exactly = 0) { previous.write(any(), any(), any(), any(), any()) }
verify(exactly = 1) {
replacement.write(Severity.Info, any(), "after replace", null, true)
}
}
@Test
fun `addLogWriter appends without removing existing writers`() {
// Arrange
val first: TangemLogger.LogWriter = mockk(relaxed = true)
every { first.isLoggable(any(), any()) } returns true
val second: TangemLogger.LogWriter = mockk(relaxed = true)
every { second.isLoggable(any(), any()) } returns true
TangemLogger.setLogWriters(listOf(first))
TangemLogger.addLogWriter(second)
// Act
TangemLogger.d("broadcast")
// Assert
verify(exactly = 1) { first.write(Severity.Debug, any(), "broadcast", null, true) }
verify(exactly = 1) { second.write(Severity.Debug, any(), "broadcast", null, true) }
}
@Test
fun `setLogWriters with empty list silences all output`() {
// Arrange
TangemLogger.setLogWriters(emptyList())
// Act
TangemLogger.i("nobody listening")
// Assert
verify(exactly = 0) { writer.write(any(), any(), any(), any(), any()) }
}
// endregion
// region isLoggable filtering
@Test
fun `write is skipped when isLoggable returns false`() {
// Arrange
every { writer.isLoggable(any(), any()) } returns false
// Act
TangemLogger.w("filtered out")
// Assert
verify(exactly = 1) { writer.isLoggable(Severity.Warn, any()) }
verify(exactly = 0) { writer.write(any(), any(), any(), any(), any()) }
}
@Test
fun `each writer is filtered independently by its own isLoggable`() {
// Arrange
val accepting: TangemLogger.LogWriter = mockk(relaxed = true)
every { accepting.isLoggable(any(), any()) } returns true
val rejecting: TangemLogger.LogWriter = mockk(relaxed = true)
every { rejecting.isLoggable(any(), any()) } returns false
TangemLogger.setLogWriters(listOf(accepting, rejecting))
// Act
TangemLogger.i("partial")
// Assert
verify(exactly = 1) { accepting.write(Severity.Info, any(), "partial", null, true) }
verify(exactly = 0) { rejecting.write(any(), any(), any(), any(), any()) }
}
@Test
fun `LogWriter isLoggable defaults to true`() {
// Arrange
val realWriter = object : TangemLogger.LogWriter {
override fun write(
severity: Severity,
tag: String,
message: String,
throwable: Throwable?,
shouldSanitize: Boolean,
) = Unit
}
// Act + Assert
Severity.entries.forEach { severity ->
Truth.assertThat(realWriter.isLoggable(severity, "anyTag")).isTrue()
}
}
// endregion
// region Tag resolution
@Test
fun `resolved tag falls back to caller class name when no tag is provided`() {
// Act
TangemLogger.d("no tag")
// Assert
verify(exactly = 1) {
writer.write(Severity.Debug, "TangemLoggerTest", "no tag", null, true)
}
}
@Test
fun `withTag returns a TaggedLogger that uses the supplied tag`() {
// Arrange
val tagged = TangemLogger.withTag("MyFeature")
// Act
tagged.i("hello")
// Assert
verify(exactly = 1) {
writer.write(Severity.Info, "MyFeature", "hello", null, true)
}
}
// endregion
// region TaggedLogger
@Test
fun `TaggedLogger dispatches each severity with its tag, throwable and shouldSanitize flag`() {
// Arrange
val tagged = TangemLogger.withTag("Tag")
val throwable = RuntimeException("oops")
// Act
tagged.v("v")
tagged.d("d")
tagged.i("i")
tagged.w("w")
tagged.e("e", throwable)
tagged.a("a", shouldSanitize = false)
// Assert
verifyOrder {
writer.write(Severity.Verbose, "Tag", "v", null, true)
writer.write(Severity.Debug, "Tag", "d", null, true)
writer.write(Severity.Info, "Tag", "i", null, true)
writer.write(Severity.Warn, "Tag", "w", null, true)
writer.write(Severity.Error, "Tag", "e", throwable, true)
writer.write(Severity.Assert, "Tag", "a", null, false)
}
}
@Test
fun `TaggedLogger respects writer isLoggable filtering`() {
// Arrange
every { writer.isLoggable(any(), any()) } returns false
val tagged = TangemLogger.withTag("Filtered")
// Act
tagged.e("ignored")
// Assert
verify(exactly = 1) { writer.isLoggable(Severity.Error, "Filtered") }
verify(exactly = 0) { writer.write(any(), any(), any(), any(), any()) }
}
// endregion
}