
Lightweight structured logging with key-value attributes, lazy message evaluation, thread/coroutine context propagation, per-logger level overrides, multiple sinks, test sink, and extensible custom sinks.
A lightweight, structured logging library for Kotlin Multiplatform. Supports Android, iOS, macOS, JVM, JS (Node.js & Browser), Wasm/JS, Linux, and MinGW.
Log.* for quick debugging; structured Logger for production useVERBOSE, DEBUG, INFO, WARN, ERROR, FATAL, OFF
Map<String, Any?>) to any log eventNSThread dictionary on Apple,
scoped save/restore everywhere elseLogger.withContext(...) returns a logger carrying a LogContext as a
field, so it is correct on every platform under any dispatcher - no ambient state involvedlogger-coroutines module whose withLogContext
carries LogContext in the coroutine context: it survives suspension and thread hops, nests,
and is never visible to another coroutine. On JVM/Android it is a real ThreadContextElement,
so unbound loggers see it ambiently too - see Coroutine Support
"main" elsewhereNSLog on Apple, console on JS/Wasm,
stdout on JVM/Linux/MinGWTestSink for asserting log output in unit testsLogSink to send logs anywhere (remote, file, analytics)// build.gradle.kts (commonMain)
commonMain.dependencies {
implementation("io.github.shivathapaa:logger:2.0.0")
}Add logger-coroutines if you use coroutines and need LogContext to survive suspension points
and thread switches (e.g. Dispatchers.IO or Dispatchers.Default on JVM/Android):
// build.gradle.kts (commonMain)
commonMain.dependencies {
implementation("io.github.shivathapaa:logger:2.0.0")
implementation("io.github.shivathapaa:logger-coroutines:2.0.0")
}For Android-only apps, platform-specific artifacts are also available:
// build.gradle.kts (androidMain)
androidMain.dependencies {
implementation("io.github.shivathapaa:logger-android:2.0.0")
}For the full list of platform-specific artifacts, see Maven Central.
For quick logging without any setup, use the Log API. No configuration required - it uses
VERBOSE as the default minimum level and writes to the platform-native output.
Log.v("Verbose message")
Log.d("Debug message")
Log.i("App started")
Log.w("Warning message")
Log.e("Error occurred")
Log.fatal("Critical failure") // always throws after logging
// With exceptions
Log.e("Operation failed", throwable = exception)
Log.w("Recovered from error", throwable = exception)
// With a custom tag
Log.i("User logged in", tag = "Auth")
Log.d("Request completed", tag = "Network")// Set once during app initialization
Log.setDefaultTag("MyApp")
// All subsequent calls use this tag
Log.i("This uses 'MyApp' tag")class UserViewModel {
private val log = Log.withClassTag<UserViewModel>()
fun login() {
log.d { "Login attempt started" }
try {
// login logic
log.i { "Login successful" }
} catch (e: Exception) {
log.e(throwable = e) { "Login failed" }
}
}
}object NetworkModule {
private val log = Log.withTag("Network")
fun fetchData() {
log.d { "Starting API request" }
log.i { "Request completed successfully" }
}
}class MyViewModel {
fun doWork() {
loggerD { "Starting work" } // uses "MyViewModel" as tag
loggerI { "Work in progress" }
try {
riskyOperation()
} catch (e: Exception) {
loggerE(e) { "Work failed" }
}
}
}Available extensions: loggerV(), loggerD(), loggerI(), loggerW(), loggerE(),
loggerFatal()
fun main() {
val config = LoggerConfig.Builder()
.minLevel(LogLevel.DEBUG)
.addSink(DefaultLogSink())
.build()
LoggerFactory.install(config)
}val logger = LoggerFactory.get("MyApp")logger.info { "Application started" }
logger.debug { "Debug information" }
logger.error { "Something went wrong" }Levels are ordered from least to most severe. Setting minLevel passes that level and everything
above it:
| Level | Emoji | Usage |
|---|---|---|
| VERBOSE | 💜 | Most detailed, development only |
| DEBUG | 💚 | Debugging information |
| INFO | 💙 | General informational messages |
| WARN | 💛 | Potential issues, non-critical |
| ERROR | ❤️ | Errors and failures that need investigation |
| FATAL | 💔 | Unrecoverable errors - flushes sinks and throws |
| OFF | ❌ | Disables all logging |
Log messages use lambda syntax - the message is only computed if the log level is enabled:
// Bad: always computes the expensive operation
logger.debug("Result: ${expensiveComputation()}")
// Good: only computes if DEBUG is enabled
logger.debug { "Result: ${expensiveComputation()}" }Attach key-value metadata to logs for machine-readable output:
logger.info(
attrs = {
attr("userId", 12345)
attr("action", "login")
attr("duration", 1500)
}
) { "User logged in" }Output:
[INFO] MyApp - User logged in | attrs={userId=12345, action=login, duration=1500}
try {
riskyOperation()
} catch (e: Exception) {
logger.error(
throwable = e,
attrs = {
attr("operation", "riskyOperation")
attr("retryCount", 3)
}
) { "Operation failed after retries" }
}val config = LoggerConfig.Builder()
.minLevel(LogLevel.INFO)
.addSink(DefaultLogSink())
.build()
LoggerFactory.install(config)val config = LoggerConfig.Builder()
.minLevel(LogLevel.INFO) // default for all loggers
.override("NetworkModule", LogLevel.DEBUG) // verbose network logs
.override("ThirdPartySDK", LogLevel.ERROR) // silence noisy SDK
.addSink(DefaultLogSink())
.build()
LoggerFactory.install(config)
val networkLogger = LoggerFactory.get("NetworkModule") // uses DEBUG
val sdkLogger = LoggerFactory.get("ThirdPartySDK") // uses ERROR
val appLogger = LoggerFactory.get("MyApp") // uses INFO (default)val config = LoggerConfig.Builder()
.minLevel(LogLevel.DEBUG)
.addSink(DefaultLogSink()) // platform-native output
.addSink(FileSink("app.log")) // file output (custom)
.addSink(RemoteLogSink { payload -> send(payload) }) // remote logging
.build()Add common fields to all logs within a scope:
val context = LogContext(
values = mapOf(
"requestId" to "req-123",
"userId" to 456
)
)
LogContextHolder.withContext(context) {
logger.info { "Processing request" }
logger.debug { "Validating input" }
logger.info { "Request completed" }
}Output:
[INFO] MyApp - Processing request | ctx={requestId=req-123, userId=456}
[DEBUG] MyApp - Validating input | ctx={requestId=req-123, userId=456}
[INFO] MyApp - Request completed | ctx={requestId=req-123, userId=456}
Contexts are automatically merged. Inner keys override outer keys on collision:
val traceContext = LogContext(mapOf("traceId" to "trace-123"))
val spanContext = LogContext(mapOf("spanId" to "span-456"))
LogContextHolder.withContext(traceContext) {
logger.info { "Outer scope" } // has traceId
LogContextHolder.withContext(spanContext) {
logger.info { "Inner scope" } // has traceId + spanId
}
logger.info { "Back to outer" } // has traceId only
}withContext propagates the block's return value, so you can use it inline:
val result = LogContextHolder.withContext(context) {
processRequest() // return value is propagated
}KMP Logger ships with an optional logger-coroutines module that provides LogContext
propagation across suspension points. On JVM/Android this extends to multi-threaded
dispatchers; on other targets the guarantee is weaker - see
How It Works for the exact per-platform behaviour.
LogContextHolder.withContext is synchronous. On JVM/Android, context is stored in a
ThreadLocal. When a coroutine suspends and resumes on a different thread (e.g. with
Dispatchers.IO), the ThreadLocal on the new thread is empty - the context is lost.
Logger.withContext returns a logger that carries the context as a field. There is no
thread-local and no coroutine-resumption machinery involved, so it is correct on every
platform and under any dispatcher - including multi-threaded ones on Kotlin/Native, where
ambient propagation is not:
suspend fun handleRequest(requestId: String) {
val log = LoggerFactory.get("Api").withContext("requestId" to requestId)
log.info { "Starting request" }
withContext(Dispatchers.Default) {
delay(100)
log.debug { "Fetching data" } // still carries requestId - it travels with the object
}
}Bound contexts merge with chaining (later values win) and merge with any ambient context at log time, with bound values winning on collision. This is the recommended approach unless you specifically need ambient propagation across code you don't control.
The logger-coroutines module provides withLogContext. On JVM/Android - where the
ThreadLocal problem above actually exists - it solves it completely, via a real
ThreadContextElement. On other targets the guarantee is weaker
(see below):
import dev.shivathapaa.logger.coroutines.withLogContext
suspend fun handleRequest(requestId: String) {
val ctx = LogContext(mapOf("requestId" to requestId))
withLogContext(ctx) {
logger.info { "Starting request" }
withContext(Dispatchers.IO) { // thread hop - context still present
delay(100) // suspension - context still present
logger.debug { "Fetching data" }
}
logger.info { "Request complete" }
}
}withLogContext carries the context in the coroutine context, so on every platform it
survives suspension and thread hops, nests correctly, and can never be observed by a
different coroutine. Read it with currentLogContext(), or attach it to a logger with
withActiveLogContext().
The platforms differ in one respect only - whether the context is also mirrored into the
ambient LogContextHolder, which is what makes it visible to a plain unbound logger:
| Platform | Mechanism | Visible to an unbound logger.info { }? |
|---|---|---|
| JVM / Android |
LogContextElement is a real ThreadContextElement. The dispatcher calls updateThreadContext/restoreThreadContext on every thread switch, keeping the ThreadLocal-backed holder in sync. |
✅ Yes |
| iOS / macOS / Linux / MinGW / JS / WasmJS |
kotlinx.coroutines.ThreadContextElement does not exist outside JVM, so nothing can keep thread state in sync with coroutine resumption. The context lives in the coroutine context only. |
❌ No - bind it with withActiveLogContext()
|
It used to be, and it was a bug. Kotlin/Native is not single-threaded -
Dispatchers.Defaultis a multi-threaded worker pool - and a thread-local does not help, because pool threads are reused: coroutine B writes its context into thread T's slot, then coroutine A resumes on T and reads B's context. Verified, not theoretical:NativeLogContextIsolationTestreproduced exactly that on macosArm64.Since
ThreadContextElementis JVM-only, that mirroring cannot be made correct off JVM, so it was removed rather than documented around. The context is now never wrong on any target - it is simply not readable without asuspendcall, because resolving the coroutine context requires one.
Portable rule: bind the context to the logger (withActiveLogContext() or
Logger.withContext) and your code behaves identically on every target. Ambient visibility on
JVM/Android is a bonus, not the contract.
withLogContext calls nest and merge just like withContext. Inner values override outer values
for the same key:
withLogContext(LogContext(mapOf("traceId" to "trace-123"))) {
logger.info { "Outer" } // traceId=trace-123
withLogContext(LogContext(mapOf("spanId" to "span-456"))) {
logger.info { "Inner" } // traceId=trace-123, spanId=span-456
}
logger.info { "Back to outer" } // traceId=trace-123
}To attach a fixed context to an entire scope, add LogContextElement directly to the scope's
coroutine context:
import dev.shivathapaa.logger.coroutines.LogContextElement
val scope = CoroutineScope(
Dispatchers.IO + LogContextElement(LogContext(mapOf("service" to "payment-api")))
)
scope.launch {
logger.info { "All coroutines in this scope carry service=payment-api" }
}Inside a withLogContext block you can read the current LogContextElement from the coroutine
context:
withLogContext(LogContext(mapOf("requestId" to "req-1"))) {
val element = currentCoroutineContext()[LogContextElement]
println(element?.context) // LogContext(values={requestId=req-1})
}The core logger module also exposes LogContextHolder.withSuspendingContext. It accepts a
suspending block but holds the context in thread state for the block's duration, with
nothing to reinstall it on resumption. If the coroutine resumes on another thread, or a
sibling coroutine interleaves, it yields the wrong context rather than a missing one - and
neither condition can be detected from inside the function.
It is deprecated as of 2.0.0. Replace it with whichever fits:
// Portable: bind the context to the logger. Correct on every platform and dispatcher.
val log = LoggerFactory.get("Api").withContext("requestId" to id)
log.info { "Context is present" }
// Or scope it to a coroutine block and bind the active context:
withLogContext(LogContext(mapOf("requestId" to id))) {
val log = LoggerFactory.get("Api").withActiveLogContext()
delay(100)
log.info { "Context is present" }
}| Scenario | API |
|---|---|
| Known context, any platform (recommended default) |
Logger.withContext(ctx) - bound to the logger, no ambient state |
Inside a withLogContext block, any platform |
Logger.withActiveLogContext() - binds the coroutine's active context |
| Scoping a context around a coroutine block |
withLogContext from logger-coroutines
|
| Reading the active context yourself |
currentLogContext() from logger-coroutines
|
| Non-coroutine, synchronous code | LogContextHolder.withContext |
| Ambient visibility to unbound loggers |
withLogContext - JVM/Android only
|
| Fixed context on a whole scope (JVM/Android) |
LogContextElement from logger-coroutines
|
LogContextHolder.withSuspendingContext |
Deprecated - holds context in thread state across suspension, so it can return the wrong context |
Binding (Logger.withContext / withActiveLogContext) is correct on every platform under
every dispatcher, because the context is a field on the logger rather than ambient state.
Ambient propagation exists for the case binding cannot serve: getting context into code you
don't control - and only JVM/Android can offer it.
Formatters convert a LogEvent into a string. Pass one to a sink. All built-in formatters
are obtained via LogFormatters:
Concise single-line: level, tag, message, stack trace if present.
ConsoleSink(LogFormatters.default(showEmoji = true))
// 💙 [INFO] PaymentService: Payment acceptedSingle-line with inline attributes.
ConsoleSink(LogFormatters.compact(showEmoji = false))Multi-line human-readable output with timestamps and thread names.
ConsoleSink(
LogFormatters.pretty(
showEmoji = true,
includeTimestamp = true,
includeThread = true,
prettyPrint = true
)
)Single-line JSON, safe for log aggregation platforms (Elasticsearch, Datadog, Splunk, Loki).
RemoteLogSink(
logFormatter = LogFormatters.json(showEmoji = false)
) { payload -> myApi.send(payload) }When showEmoji = true, the emoji is added as a "levelEmoji" field inside the JSON object -
not prepended before it - so the output is always valid JSON:
{
"levelEmoji": "💙",
"level": "INFO",
"logger": "PaymentService",
"timestamp": 1711785600000,
"message": "Payment accepted",
"thread": "main"
}val myFormatter = LogEventFormatter { event ->
"[${event.level}] ${event.loggerName}: ${event.message}"
}
ConsoleSink(myFormatter)Platform-native logging (recommended for most use cases):
.addSink(DefaultLogSink())android.util.Log (Logcat)NSLog
stdout
console
Console output with a configurable formatter:
.addSink(ConsoleSink())Formats the event and forwards it to any destination via a send lambda:
.addSink(
RemoteLogSink(
logFormatter = LogFormatters.json(showEmoji = false)
) { payload ->
myHttpClient.post("/logs", payload)
}
)Captures events for assertion in unit tests:
val testSink = TestSink()
LoggerFactory.install(
LoggerConfig.Builder()
.minLevel(LogLevel.DEBUG)
.addSink(testSink)
.build()
)
logger.info { "Test message" }
assertEquals(1, testSink.events.size)
assertEquals(LogLevel.INFO, testSink.events[0].level)
assertEquals("Test message", testSink.events[0].message)Both APIs share the same pipeline. LoggerFactory.install() configuration - sinks, level
filtering, and per-tag overrides - applies to Log.* calls as well as Logger calls.
| Feature | Simple Log API |
Structured Logger API |
|---|---|---|
| Setup required | No (auto-initialized) | No (auto-initialized) |
Respects LoggerFactory sinks |
✅ Yes | ✅ Yes |
| Respects level overrides | ✅ Yes | ✅ Yes |
Testable via TestSink
|
✅ Yes | ✅ Yes |
| Lazy message evaluation | ✅ Yes (via withTag/withClassTag) |
✅ Yes |
| Direct string message | ✅ Yes (Log.i("msg")) |
❌ No (lambda required) |
| Structured attributes | ❌ No | ✅ Yes |
| Set log context | ❌ No | ✅ Via LogContextHolder
|
| Carries active context | ✅ Yes (thread-local) | ✅ Yes (thread-local) |
Use Simple Log API when:
Use Structured Logger API when:
logger.info(
attrs = {
attr("method", "POST")
attr("path", "/api/users")
attr("statusCode", 201)
attr("duration", 234)
attr("ip", "192.168.1.1")
}
) { "HTTP request" }logger.debug(
attrs = {
attr("query", "SELECT * FROM users WHERE id = ?")
attr("params", listOf(123))
attr("executionTime", 45)
attr("rowsAffected", 1)
}
) { "Query executed" }logger.info(
attrs = {
attr("event", "order_created")
attr("orderId", "ORD-001")
attr("userId", 789)
attr("total", 99.99)
attr("items", 3)
}
) { "Order created successfully" }suspend fun processOrder(orderId: String, userId: Int) {
val ctx = LogContext(mapOf("orderId" to orderId, "userId" to userId))
withLogContext(ctx) {
logger.info { "Processing order" }
val payment = withContext(Dispatchers.IO) {
logger.debug { "Charging payment" } // context present on IO thread
chargePayment(orderId)
}
logger.info(attrs = { attr("paymentId", payment.id) }) { "Order complete" }
}
}@Test
fun logErrorWhenOperationFails() {
val testSink = TestSink()
LoggerFactory.install(
LoggerConfig.Builder()
.minLevel(LogLevel.DEBUG)
.addSink(testSink)
.build()
)
val logger = LoggerFactory.get("MyClass")
logger.error(attrs = { attr("operation", "save") }) { "Failed to save data" }
assertEquals(1, testSink.events.size)
val event = testSink.events[0]
assertEquals(LogLevel.ERROR, event.level)
assertEquals("Failed to save data", event.message)
assertEquals("MyClass", event.loggerName)
assertEquals("save", event.attributes["operation"])
}@Test
fun propagatesContextToNestedLogs() {
val testSink = TestSink()
LoggerFactory.install(
LoggerConfig.Builder()
.minLevel(LogLevel.DEBUG)
.addSink(testSink)
.build()
)
val logger = LoggerFactory.get("ContextTest")
LogContextHolder.withContext(LogContext(mapOf("requestId" to "req-123"))) {
logger.info { "Log 1" }
logger.info { "Log 2" }
}
testSink.events.forEach { event ->
assertEquals("req-123", event.context.values["requestId"])
}
}@Test
fun coroutineContextSurvivesSuspension() = runTest {
val testSink = TestSink()
LoggerFactory.install(
LoggerConfig.Builder()
.minLevel(LogLevel.DEBUG)
.addSink(testSink)
.build()
)
val logger = LoggerFactory.get("CoroutineTest")
withLogContext(LogContext(mapOf("requestId" to "req-123"))) {
delay(10)
logger.info { "After delay" }
}
assertEquals("req-123", testSink.events[0].context.values["requestId"])
}class FileSink(private val filename: String) : LogSink {
private val file = File(filename)
override fun emit(event: LogEvent) {
val line = "[${event.level}] ${event.loggerName}: ${event.message}\n"
file.appendText(line)
}
override fun flush() {
// ensure all data is written
}
}A production sink that reports to Firebase Crashlytics on both Android and iOS. This is the
canonical way to write a platform-specific sink in KMP: one expect factory, one actual per
target, each talking to that platform's native Crashlytics SDK. (A target that doesn't ship
Firebase can opt out by returning null - shown at the end.)
// commonMain
internal expect fun crashlyticsSink(): LogSink?// androidMain
internal class CrashlyticsLogSink : LogSink {
override fun emit(event: LogEvent) {
if (event.level.ordinal < LogLevel.WARN.ordinal) return // WARN+ only
try {
val crashlytics = FirebaseCrashlytics.getInstance()
val msg = event.message ?: ""
crashlytics.log("[${event.level}] ${event.loggerName}: $msg") // breadcrumb
val throwable = event.throwable
if (throwable != null &&
event.level.ordinal >= LogLevel.ERROR.ordinal &&
!throwable.isTransportFailure() // skip offline/timeout noise
) {
crashlytics.setCustomKey("logger.tag", event.loggerName)
crashlytics.setCustomKey("logger.level", event.level.name)
event.attributes.forEach { (k, v) -> crashlytics.setCustomKey("attr.$k", v?.toString() ?: "null") }
event.context.values.forEach { (k, v) -> crashlytics.setCustomKey("ctx.$k", v?.toString() ?: "null") }
crashlytics.recordException(throwable) // non-fatal
}
} catch (_: Throwable) {
// Crashlytics not initialized yet - a sink must never throw.
}
}
// Don't report expected offline/timeout IOExceptions as crashes.
private fun Throwable.isTransportFailure(): Boolean = this is IOException
}
internal actual fun crashlyticsSink(): LogSink? = CrashlyticsLogSink()On iOS the same contract is fulfilled against the Firebase iOS SDK (assumed already set up in your
project). Kotlin/Native Throwables aren't NSErrors, so an ERROR is recorded by wrapping it in an
FIRExceptionModel:
// iosMain
internal class CrashlyticsLogSink : LogSink {
override fun emit(event: LogEvent) {
if (event.level.ordinal < LogLevel.WARN.ordinal) return // WARN+ only
val crashlytics = FIRCrashlytics.crashlytics()
val msg = event.message ?: ""
crashlytics.log("[${event.level}] ${event.loggerName}: $msg") // breadcrumb
val throwable = event.throwable
if (throwable != null && event.level.ordinal >= LogLevel.ERROR.ordinal) {
// (apply the same transport-failure filter as Android to skip expected offline errors -
// on Apple that's a Ktor/Darwin error type, not IOException)
crashlytics.setCustomValue(event.loggerName, forKey = "logger.tag")
crashlytics.setCustomValue(event.level.name, forKey = "logger.level")
event.attributes.forEach { (k, v) -> crashlytics.setCustomValue(v?.toString() ?: "null", forKey = "attr.$k") }
event.context.values.forEach { (k, v) -> crashlytics.setCustomValue(v?.toString() ?: "null", forKey = "ctx.$k") }
val model = FIRExceptionModel.exceptionModelWithName(
name = throwable::class.qualifiedName ?: "KotlinException",
reason = throwable.message ?: msg
)
model.setStackTrace(throwable.getStackTrace().map { FIRStackFrame.stackFrameWithSymbol(it) })
crashlytics.recordExceptionModel(model) // non-fatal
}
}
}
internal actual fun crashlyticsSink(): LogSink? = CrashlyticsLogSink()Opting a platform out. If a target doesn't ship Firebase, return null there. DefaultLogSink
still routes that platform's logs to its native channel (NSLog on Apple, console on JS/Wasm,
stdout on JVM/Linux/MinGW), so nothing is lost - you simply forgo crash-reporting on that target.
The expect/actual split is exactly what lets one target integrate Crashlytics while another opts
out:
// iosMain - opt out: no Firebase on this target
internal actual fun crashlyticsSink(): LogSink? = nullKey ideas (both platforms): gate breadcrumbs to WARN+ and non-fatal exception recording to
ERROR+ with a throwable; promote structured attributes -> attr.* and context.values ->
ctx.* custom keys so they land on the Crashlytics issue; keep a sink from ever throwing (the
Android actual wraps getInstance() in try/catch for the pre-init window); and use
expect/actual to integrate - or deliberately opt out - per target.
FacebookLogSink is Android-only - it uses android.os.Bundle and AppEventsLogger - so it
lives in androidMain:
// androidMain
internal class FacebookLogSink(
private val minLevel: LogLevel = LogLevel.INFO,
private val appEventsLogger: AppEventsLogger
) : LogSink {
override fun emit(event: LogEvent) {
if (event.level < minLevel) return
val eventName = when (event.level) {
LogLevel.INFO -> "app_log_info"
LogLevel.WARN -> "app_log_warn"
LogLevel.ERROR -> "app_log_error"
LogLevel.FATAL -> "app_log_fatal"
else -> "app_log"
}
val params = Bundle().apply {
putString("logger", event.loggerName)
putString("level", event.level.name)
putString("thread", event.thread)
putString("message", event.message?.take(100)) // message is nullable
event.attributes.forEach { (k, v) ->
putString("attr_${k.safeKey()}", v?.toString()?.take(100))
}
event.context.values.forEach { (k, v) ->
putString("ctx_${k.safeKey()}", v?.toString()?.take(100))
}
event.throwable?.let {
putString("exception", it::class.simpleName)
putString("exception_message", it.message?.take(100))
}
}
appEventsLogger.logEvent(eventName, params)
}
private fun String.safeKey(): String =
lowercase().replace("[^a-z0-9_]".toRegex(), "_").take(40)
}Register it through the same expect/actual sink-factory hook used for Crashlytics - the Android
actual builds it from an AppEventsLogger (which needs a Context), and every non-Android target
returns null:
// commonMain
internal expect fun facebookSink(): LogSink?
// androidMain - construct from your app's AppEventsLogger
internal actual fun facebookSink(): LogSink? =
FacebookLogSink(appEventsLogger = AppEventsLogger.newLogger(appContext))
// iosMain / jsMain / … - no Facebook SDK on these targets
internal actual fun facebookSink(): LogSink? = nullThen add it in your installer just like the Crashlytics sink: facebookSink()?.let { builder.addSink(it) }.
Wrap another sink to scrub secrets out of the message, attributes, and context before they
reach a remote destination (Crashlytics, a log aggregator, etc.). It delegates to an inner sink after
sanitizing, so you compose it around any other sink - e.g. SanitizingSink(crashlyticsSink()).
class SanitizingSink(private val delegate: LogSink) : LogSink {
override fun emit(event: LogEvent) {
val message = event.message?.let(::scrub)
val attrs = if (event.attributes.isNotEmpty())
event.attributes.mapValues { (k, v) -> scrubAttr(k, v) } else event.attributes
val context = if (event.context.values.isNotEmpty())
event.context.copy(values = event.context.values.mapValues { (k, v) -> scrubAttr(k, v) })
else event.context
// Nothing sensitive -> forward the original event untouched.
if (message === event.message && attrs === event.attributes && context === event.context) {
delegate.emit(event); return
}
delegate.emit(event.copy(message = message, attributes = attrs, context = context))
}
override fun flush() = delegate.flush()
private fun scrubAttr(key: String, value: Any?): Any? {
val lowered = key.lowercase()
return when {
SENSITIVE_KEYS.any { lowered.contains(it) } -> REDACTED
value is String -> scrub(value)
else -> value
}
}
private fun scrub(input: String): String =
input.replace(JWT_PATTERN, REDACTED)
.replace(BEARER_PATTERN, "Bearer $REDACTED")
.replace(NAMED_VALUE_PATTERN) { "${it.groupValues[1]}=$REDACTED" }
private companion object {
const val REDACTED = "***REDACTED***"
val SENSITIVE_KEYS = setOf(
"password", "passcode", "pin", "secret", "token",
"accesstoken", "refreshtoken", "authorization", "otp", "apikey", "api_key"
)
val JWT_PATTERN = Regex("""\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b""")
val BEARER_PATTERN = Regex("""Bearer\s+[A-Za-z0-9._-]+""", RegexOption.IGNORE_CASE)
val NAMED_VALUE_PATTERN = Regex(
"""(?i)\b(password|passcode|pin|secret|token|access_?token|refresh_?token|authorization|otp|api_?key)\s*=\s*[^\s,;&]+"""
)
}
}It matches sensitive keys by substring (so accessToken, X-Api-Key are caught) and scrubs
sensitive values in free-text messages via the JWT, Bearer …, and name=value patterns.
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
LoggerFactory.install(
LoggerConfig.Builder()
.minLevel(if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO)
.addSink(DefaultLogSink())
.build()
)
}
}fun initializeApp() {
LoggerFactory.install(
LoggerConfig.Builder()
.minLevel(LogLevel.DEBUG)
.addSink(DefaultLogSink())
.build()
)
}fun main() {
LoggerFactory.install(
LoggerConfig.Builder()
.minLevel(LogLevel.DEBUG)
.addSink(DefaultLogSink())
.build()
)
// your application code
}These patterns are distilled from a production Kotlin Multiplatform app that uses KMP Logger across
its networking, caching, analytics, and feature modules. They combine the pieces above -
LoggerConfig, custom sinks, per-tag overrides, and coroutine context - into a working setup.
Centralize configuration in a single installer, called once at startup before any Log.* call and
before your DI container (code logs during DI setup itself, so the logger must be a process-global,
not a DI singleton):
object LoggerInitializer {
fun install(isDebug: Boolean) {
Log.setDefaultTag("MyApp")
val builder = LoggerConfig.Builder()
.minLevel(if (isDebug) LogLevel.VERBOSE else LogLevel.INFO)
.addSink(DefaultLogSink()) // Logcat / NSLog / stdout / console
if (isDebug) {
builder.addSink(
ConsoleSink(
LogFormatters.pretty(
showEmoji = true, includeTimestamp = true,
includeThread = true, prettyPrint = true
)
)
)
}
// Crashlytics wrapped in the sanitizer so secrets never reach Firebase.
crashlyticsSink()?.let { builder.addSink(SanitizingSink(it)) }
if (!isDebug) { // Quiet chatty modules in release
builder.override("Network", LogLevel.WARN)
.override("WebSocket", LogLevel.WARN)
.override("Ktor", LogLevel.WARN)
}
LoggerFactory.install(builder.build())
}
}Wire it as the very first thing on each platform entry point:
// Android - Application.onCreate()
FirebaseApp.initializeApp(this)
LoggerInitializer.install(isDebug = BuildConfig.DEBUG) // BEFORE startKoin / any Log usage
startKoin { /* ... */ }// iOS - bootstrap, once per process
LoggerInitializer.install(isDebug = isDebugBuild())
startKoin { /* ... */ }Route analytics through the same log pipeline in debug so you can see events without shipping them, and swap in the real SDK for release via DI:
class StubAnalyticsHelper : AnalyticsHelper { // debug: log only, never hits Firebase
override fun logEvent(event: AnalyticsEvent) {
val params = event.params.joinToString(", ") { "${it.key}=${it.value}" }
Log.d("event '${event.type}' { $params }", tag = "Analytics")
}
override fun setUserId(userId: String?) =
Log.d("setUserId(${userId ?: "null"})", tag = "Analytics")
override fun setUserProperty(name: String, value: String?) =
Log.d("userProperty $name=${value ?: "null"}", tag = "Analytics")
}
// DI: pick implementation by build type
single<AnalyticsHelper> { if (isDebugBuild()) StubAnalyticsHelper() else FirebaseAnalyticsHelper() }Because it shares the pipeline, the release minLevel (or a .override("Analytics", LogLevel.WARN))
silences analytics tracing in production with no extra flag.
Attach the call's identity once, and every log line from that logger - plus the ctx.* keys on
any crash report - carries it automatically:
suspend fun <T> handle(response: HttpResponse): T {
val log = LoggerFactory.get("Network").withContext(
"http.method" to response.request.method.value,
"http.url" to response.request.url.toString(),
"http.status" to response.status.value,
)
return parseBody(response, log) // every log.* call carries the http.* context
}Binding works on every target. If you need the context to reach loggers you don't pass it to -
for example code deeper in the stack you don't own - use withLogContext and bind at the log
site with withActiveLogContext(), or rely on ambient visibility on JVM/Android:
withLogContext(LogContext(values = mapOf("http.url" to url))) {
val log = LoggerFactory.get("Network").withActiveLogContext()
log.info { "Request finished" }
}Pair it with transport-aware levels so expected offline/timeout failures are WARN (a visible
breadcrumb, not reported) while real faults are ERROR (recorded by the Crashlytics sink):
fun logError(tag: String, message: String, e: Exception?, type: ErrorType) {
if (type == ErrorType.NETWORK || type == ErrorType.TIMEOUT)
Log.w(message, tag = tag, throwable = e) // expected - breadcrumb only
else
Log.e(message, tag = tag, throwable = e) // real fault - non-fatal in Crashlytics
}This is the WARN-vs-ERROR contract the Crashlytics sink above relies on (ERROR+ with a throwable ⇒
recordException). You can also forward a third-party client's own logs into the pipeline - e.g. pipe
Ktor's logger into Log.d(message, tag = "Ktor") so it obeys the same sinks and overrides.
Three idioms, each for a different layer:
// 1. UI / ViewModels - the class name is the tag
class LoginViewModel {
private val log = Log.withClassTag<LoginViewModel>() // tag == "LoginViewModel"
fun onError(t: Throwable) = log.e("Login failed", throwable = t)
}
// 2. Infra modules - a shared constant tag, lazy lambda message, structured attrs
private val log = LoggerFactory.get("Cache")
log.debug(attrs = { attr("op", "read"); attr("key", key) }) { "READ" }
log.error(throwable = e, attrs = { attr("op", "read"); attr("key", key) }) { "Cache READ failed" }
// 3. Centralize tag strings so call sites and override() can't drift
const val NETWORK_TAG = "Network"
LoggerConfig.Builder().override(NETWORK_TAG, LogLevel.WARN) // ... and Log.d(msg, tag = NETWORK_TAG)If a library ships its own log-event type, map it onto Log.* so its output flows through your
sinks, thresholds, and overrides - no competing sink, no dependency on the logger from that module:
val bridge = SomeSdkLogger { event ->
when (event.level) {
SdkLevel.VERBOSE -> Log.v(event.message, event.tag)
SdkLevel.DEBUG -> Log.d(event.message, event.tag)
SdkLevel.INFO -> Log.i(event.message, event.tag)
SdkLevel.WARN -> Log.w(event.message, event.tag, event.throwable)
SdkLevel.ERROR -> Log.e(event.message, event.tag, event.throwable)
}
}Now the SDK's logs land in Logcat/NSLog in debug and your sanitized Crashlytics sink in release, tagged under its own name.
// Good
logger.debug { "Cache size: ${cache.size}" }
logger.info { "User logged in successfully" }
logger.warn { "API rate limit approaching" }
logger.error { "Failed to connect to database" }
// Bad
logger.error { "User clicked button" } // not an error
logger.debug { "Critical system failure" } // use ERROR or FATAL// Good - lambda only evaluated if level is enabled
logger.debug { "User: ${user.toDetailedString()}" }
// Bad - always evaluates regardless of level
logger.debug("User: ${user.toDetailedString()}")// Good - machine-readable, searchable
logger.info(
attrs = {
attr("userId", userId)
attr("duration", duration)
}
) { "Request completed" }
// Bad - hard to parse programmatically
logger.info { "Request completed for user $userId in ${duration}ms" }// Good - set once for the whole scope
LogContextHolder.withContext(LogContext(mapOf("requestId" to requestId))) {
logger.info { "Starting request" }
processRequest()
logger.info { "Request completed" }
}
// Bad - repeating the same field everywhere
logger.info(attrs = { attr("requestId", requestId) }) { "Starting request" }
logger.info(attrs = { attr("requestId", requestId) }) { "Request completed" }// Good - safe across thread hops and suspension
withLogContext(LogContext(mapOf("requestId" to requestId))) {
withContext(Dispatchers.IO) { fetchData() }
}
// Risky on JVM/Android - context lost after thread switch
LogContextHolder.withContext(LogContext(mapOf("requestId" to requestId))) {
withContext(Dispatchers.IO) { fetchData() } // context may be missing here
}// Good
logger.info(attrs = { attr("userId", userId) }) { "User authenticated" }
// Bad
logger.info { "User logged in with password: $password" }minLevel to reduce overhead in productionLoggerFactory.install(), the default minimum level is VERBOSE - all logs
should appear by defaultLoggerFactory.install(), verify minLevel is not filtering your logs.override() - they take precedence over minLevel
// The logger name must EXACTLY match the override key
val config = LoggerConfig.Builder()
.override("MyLogger", LogLevel.ERROR) // ← exact string
.build()
val logger = LoggerFactory.get("MyLogger") // ← must matchYou must call LogContextHolder.withContext() - simply creating a LogContext object does nothing:
// Correct
LogContextHolder.withContext(context) {
logger.info { "Has context" }
}
// Wrong - context object exists but is never applied
val context = LogContext(mapOf("key" to "value"))
logger.info { "No context" }If your context disappears after delay() or a thread switch on JVM/Android, you are using the
synchronous LogContextHolder.withContext inside a coroutine. Switch to withLogContext from the
logger-coroutines module:
// Before (context lost after thread switch on JVM/Android)
LogContextHolder.withContext(ctx) {
withContext(Dispatchers.IO) { ... }
}
// After (context always present)
withLogContext(ctx) {
withContext(Dispatchers.IO) { ... }
}Apache License 2.0 - see the LICENSE file for details.
Thanks for the ⭐ - it means a lot!
A lightweight, structured logging library for Kotlin Multiplatform. Supports Android, iOS, macOS, JVM, JS (Node.js & Browser), Wasm/JS, Linux, and MinGW.
Log.* for quick debugging; structured Logger for production useVERBOSE, DEBUG, INFO, WARN, ERROR, FATAL, OFF
Map<String, Any?>) to any log eventNSThread dictionary on Apple,
scoped save/restore everywhere elseLogger.withContext(...) returns a logger carrying a LogContext as a
field, so it is correct on every platform under any dispatcher - no ambient state involvedlogger-coroutines module whose withLogContext
carries LogContext in the coroutine context: it survives suspension and thread hops, nests,
and is never visible to another coroutine. On JVM/Android it is a real ThreadContextElement,
so unbound loggers see it ambiently too - see Coroutine Support
"main" elsewhereNSLog on Apple, console on JS/Wasm,
stdout on JVM/Linux/MinGWTestSink for asserting log output in unit testsLogSink to send logs anywhere (remote, file, analytics)// build.gradle.kts (commonMain)
commonMain.dependencies {
implementation("io.github.shivathapaa:logger:2.0.0")
}Add logger-coroutines if you use coroutines and need LogContext to survive suspension points
and thread switches (e.g. Dispatchers.IO or Dispatchers.Default on JVM/Android):
// build.gradle.kts (commonMain)
commonMain.dependencies {
implementation("io.github.shivathapaa:logger:2.0.0")
implementation("io.github.shivathapaa:logger-coroutines:2.0.0")
}For Android-only apps, platform-specific artifacts are also available:
// build.gradle.kts (androidMain)
androidMain.dependencies {
implementation("io.github.shivathapaa:logger-android:2.0.0")
}For the full list of platform-specific artifacts, see Maven Central.
For quick logging without any setup, use the Log API. No configuration required - it uses
VERBOSE as the default minimum level and writes to the platform-native output.
Log.v("Verbose message")
Log.d("Debug message")
Log.i("App started")
Log.w("Warning message")
Log.e("Error occurred")
Log.fatal("Critical failure") // always throws after logging
// With exceptions
Log.e("Operation failed", throwable = exception)
Log.w("Recovered from error", throwable = exception)
// With a custom tag
Log.i("User logged in", tag = "Auth")
Log.d("Request completed", tag = "Network")// Set once during app initialization
Log.setDefaultTag("MyApp")
// All subsequent calls use this tag
Log.i("This uses 'MyApp' tag")class UserViewModel {
private val log = Log.withClassTag<UserViewModel>()
fun login() {
log.d { "Login attempt started" }
try {
// login logic
log.i { "Login successful" }
} catch (e: Exception) {
log.e(throwable = e) { "Login failed" }
}
}
}object NetworkModule {
private val log = Log.withTag("Network")
fun fetchData() {
log.d { "Starting API request" }
log.i { "Request completed successfully" }
}
}class MyViewModel {
fun doWork() {
loggerD { "Starting work" } // uses "MyViewModel" as tag
loggerI { "Work in progress" }
try {
riskyOperation()
} catch (e: Exception) {
loggerE(e) { "Work failed" }
}
}
}Available extensions: loggerV(), loggerD(), loggerI(), loggerW(), loggerE(),
loggerFatal()
fun main() {
val config = LoggerConfig.Builder()
.minLevel(LogLevel.DEBUG)
.addSink(DefaultLogSink())
.build()
LoggerFactory.install(config)
}val logger = LoggerFactory.get("MyApp")logger.info { "Application started" }
logger.debug { "Debug information" }
logger.error { "Something went wrong" }Levels are ordered from least to most severe. Setting minLevel passes that level and everything
above it:
| Level | Emoji | Usage |
|---|---|---|
| VERBOSE | 💜 | Most detailed, development only |
| DEBUG | 💚 | Debugging information |
| INFO | 💙 | General informational messages |
| WARN | 💛 | Potential issues, non-critical |
| ERROR | ❤️ | Errors and failures that need investigation |
| FATAL | 💔 | Unrecoverable errors - flushes sinks and throws |
| OFF | ❌ | Disables all logging |
Log messages use lambda syntax - the message is only computed if the log level is enabled:
// Bad: always computes the expensive operation
logger.debug("Result: ${expensiveComputation()}")
// Good: only computes if DEBUG is enabled
logger.debug { "Result: ${expensiveComputation()}" }Attach key-value metadata to logs for machine-readable output:
logger.info(
attrs = {
attr("userId", 12345)
attr("action", "login")
attr("duration", 1500)
}
) { "User logged in" }Output:
[INFO] MyApp - User logged in | attrs={userId=12345, action=login, duration=1500}
try {
riskyOperation()
} catch (e: Exception) {
logger.error(
throwable = e,
attrs = {
attr("operation", "riskyOperation")
attr("retryCount", 3)
}
) { "Operation failed after retries" }
}val config = LoggerConfig.Builder()
.minLevel(LogLevel.INFO)
.addSink(DefaultLogSink())
.build()
LoggerFactory.install(config)val config = LoggerConfig.Builder()
.minLevel(LogLevel.INFO) // default for all loggers
.override("NetworkModule", LogLevel.DEBUG) // verbose network logs
.override("ThirdPartySDK", LogLevel.ERROR) // silence noisy SDK
.addSink(DefaultLogSink())
.build()
LoggerFactory.install(config)
val networkLogger = LoggerFactory.get("NetworkModule") // uses DEBUG
val sdkLogger = LoggerFactory.get("ThirdPartySDK") // uses ERROR
val appLogger = LoggerFactory.get("MyApp") // uses INFO (default)val config = LoggerConfig.Builder()
.minLevel(LogLevel.DEBUG)
.addSink(DefaultLogSink()) // platform-native output
.addSink(FileSink("app.log")) // file output (custom)
.addSink(RemoteLogSink { payload -> send(payload) }) // remote logging
.build()Add common fields to all logs within a scope:
val context = LogContext(
values = mapOf(
"requestId" to "req-123",
"userId" to 456
)
)
LogContextHolder.withContext(context) {
logger.info { "Processing request" }
logger.debug { "Validating input" }
logger.info { "Request completed" }
}Output:
[INFO] MyApp - Processing request | ctx={requestId=req-123, userId=456}
[DEBUG] MyApp - Validating input | ctx={requestId=req-123, userId=456}
[INFO] MyApp - Request completed | ctx={requestId=req-123, userId=456}
Contexts are automatically merged. Inner keys override outer keys on collision:
val traceContext = LogContext(mapOf("traceId" to "trace-123"))
val spanContext = LogContext(mapOf("spanId" to "span-456"))
LogContextHolder.withContext(traceContext) {
logger.info { "Outer scope" } // has traceId
LogContextHolder.withContext(spanContext) {
logger.info { "Inner scope" } // has traceId + spanId
}
logger.info { "Back to outer" } // has traceId only
}withContext propagates the block's return value, so you can use it inline:
val result = LogContextHolder.withContext(context) {
processRequest() // return value is propagated
}KMP Logger ships with an optional logger-coroutines module that provides LogContext
propagation across suspension points. On JVM/Android this extends to multi-threaded
dispatchers; on other targets the guarantee is weaker - see
How It Works for the exact per-platform behaviour.
LogContextHolder.withContext is synchronous. On JVM/Android, context is stored in a
ThreadLocal. When a coroutine suspends and resumes on a different thread (e.g. with
Dispatchers.IO), the ThreadLocal on the new thread is empty - the context is lost.
Logger.withContext returns a logger that carries the context as a field. There is no
thread-local and no coroutine-resumption machinery involved, so it is correct on every
platform and under any dispatcher - including multi-threaded ones on Kotlin/Native, where
ambient propagation is not:
suspend fun handleRequest(requestId: String) {
val log = LoggerFactory.get("Api").withContext("requestId" to requestId)
log.info { "Starting request" }
withContext(Dispatchers.Default) {
delay(100)
log.debug { "Fetching data" } // still carries requestId - it travels with the object
}
}Bound contexts merge with chaining (later values win) and merge with any ambient context at log time, with bound values winning on collision. This is the recommended approach unless you specifically need ambient propagation across code you don't control.
The logger-coroutines module provides withLogContext. On JVM/Android - where the
ThreadLocal problem above actually exists - it solves it completely, via a real
ThreadContextElement. On other targets the guarantee is weaker
(see below):
import dev.shivathapaa.logger.coroutines.withLogContext
suspend fun handleRequest(requestId: String) {
val ctx = LogContext(mapOf("requestId" to requestId))
withLogContext(ctx) {
logger.info { "Starting request" }
withContext(Dispatchers.IO) { // thread hop - context still present
delay(100) // suspension - context still present
logger.debug { "Fetching data" }
}
logger.info { "Request complete" }
}
}withLogContext carries the context in the coroutine context, so on every platform it
survives suspension and thread hops, nests correctly, and can never be observed by a
different coroutine. Read it with currentLogContext(), or attach it to a logger with
withActiveLogContext().
The platforms differ in one respect only - whether the context is also mirrored into the
ambient LogContextHolder, which is what makes it visible to a plain unbound logger:
| Platform | Mechanism | Visible to an unbound logger.info { }? |
|---|---|---|
| JVM / Android |
LogContextElement is a real ThreadContextElement. The dispatcher calls updateThreadContext/restoreThreadContext on every thread switch, keeping the ThreadLocal-backed holder in sync. |
✅ Yes |
| iOS / macOS / Linux / MinGW / JS / WasmJS |
kotlinx.coroutines.ThreadContextElement does not exist outside JVM, so nothing can keep thread state in sync with coroutine resumption. The context lives in the coroutine context only. |
❌ No - bind it with withActiveLogContext()
|
It used to be, and it was a bug. Kotlin/Native is not single-threaded -
Dispatchers.Defaultis a multi-threaded worker pool - and a thread-local does not help, because pool threads are reused: coroutine B writes its context into thread T's slot, then coroutine A resumes on T and reads B's context. Verified, not theoretical:NativeLogContextIsolationTestreproduced exactly that on macosArm64.Since
ThreadContextElementis JVM-only, that mirroring cannot be made correct off JVM, so it was removed rather than documented around. The context is now never wrong on any target - it is simply not readable without asuspendcall, because resolving the coroutine context requires one.
Portable rule: bind the context to the logger (withActiveLogContext() or
Logger.withContext) and your code behaves identically on every target. Ambient visibility on
JVM/Android is a bonus, not the contract.
withLogContext calls nest and merge just like withContext. Inner values override outer values
for the same key:
withLogContext(LogContext(mapOf("traceId" to "trace-123"))) {
logger.info { "Outer" } // traceId=trace-123
withLogContext(LogContext(mapOf("spanId" to "span-456"))) {
logger.info { "Inner" } // traceId=trace-123, spanId=span-456
}
logger.info { "Back to outer" } // traceId=trace-123
}To attach a fixed context to an entire scope, add LogContextElement directly to the scope's
coroutine context:
import dev.shivathapaa.logger.coroutines.LogContextElement
val scope = CoroutineScope(
Dispatchers.IO + LogContextElement(LogContext(mapOf("service" to "payment-api")))
)
scope.launch {
logger.info { "All coroutines in this scope carry service=payment-api" }
}Inside a withLogContext block you can read the current LogContextElement from the coroutine
context:
withLogContext(LogContext(mapOf("requestId" to "req-1"))) {
val element = currentCoroutineContext()[LogContextElement]
println(element?.context) // LogContext(values={requestId=req-1})
}The core logger module also exposes LogContextHolder.withSuspendingContext. It accepts a
suspending block but holds the context in thread state for the block's duration, with
nothing to reinstall it on resumption. If the coroutine resumes on another thread, or a
sibling coroutine interleaves, it yields the wrong context rather than a missing one - and
neither condition can be detected from inside the function.
It is deprecated as of 2.0.0. Replace it with whichever fits:
// Portable: bind the context to the logger. Correct on every platform and dispatcher.
val log = LoggerFactory.get("Api").withContext("requestId" to id)
log.info { "Context is present" }
// Or scope it to a coroutine block and bind the active context:
withLogContext(LogContext(mapOf("requestId" to id))) {
val log = LoggerFactory.get("Api").withActiveLogContext()
delay(100)
log.info { "Context is present" }
}| Scenario | API |
|---|---|
| Known context, any platform (recommended default) |
Logger.withContext(ctx) - bound to the logger, no ambient state |
Inside a withLogContext block, any platform |
Logger.withActiveLogContext() - binds the coroutine's active context |
| Scoping a context around a coroutine block |
withLogContext from logger-coroutines
|
| Reading the active context yourself |
currentLogContext() from logger-coroutines
|
| Non-coroutine, synchronous code | LogContextHolder.withContext |
| Ambient visibility to unbound loggers |
withLogContext - JVM/Android only
|
| Fixed context on a whole scope (JVM/Android) |
LogContextElement from logger-coroutines
|
LogContextHolder.withSuspendingContext |
Deprecated - holds context in thread state across suspension, so it can return the wrong context |
Binding (Logger.withContext / withActiveLogContext) is correct on every platform under
every dispatcher, because the context is a field on the logger rather than ambient state.
Ambient propagation exists for the case binding cannot serve: getting context into code you
don't control - and only JVM/Android can offer it.
Formatters convert a LogEvent into a string. Pass one to a sink. All built-in formatters
are obtained via LogFormatters:
Concise single-line: level, tag, message, stack trace if present.
ConsoleSink(LogFormatters.default(showEmoji = true))
// 💙 [INFO] PaymentService: Payment acceptedSingle-line with inline attributes.
ConsoleSink(LogFormatters.compact(showEmoji = false))Multi-line human-readable output with timestamps and thread names.
ConsoleSink(
LogFormatters.pretty(
showEmoji = true,
includeTimestamp = true,
includeThread = true,
prettyPrint = true
)
)Single-line JSON, safe for log aggregation platforms (Elasticsearch, Datadog, Splunk, Loki).
RemoteLogSink(
logFormatter = LogFormatters.json(showEmoji = false)
) { payload -> myApi.send(payload) }When showEmoji = true, the emoji is added as a "levelEmoji" field inside the JSON object -
not prepended before it - so the output is always valid JSON:
{
"levelEmoji": "💙",
"level": "INFO",
"logger": "PaymentService",
"timestamp": 1711785600000,
"message": "Payment accepted",
"thread": "main"
}val myFormatter = LogEventFormatter { event ->
"[${event.level}] ${event.loggerName}: ${event.message}"
}
ConsoleSink(myFormatter)Platform-native logging (recommended for most use cases):
.addSink(DefaultLogSink())android.util.Log (Logcat)NSLog
stdout
console
Console output with a configurable formatter:
.addSink(ConsoleSink())Formats the event and forwards it to any destination via a send lambda:
.addSink(
RemoteLogSink(
logFormatter = LogFormatters.json(showEmoji = false)
) { payload ->
myHttpClient.post("/logs", payload)
}
)Captures events for assertion in unit tests:
val testSink = TestSink()
LoggerFactory.install(
LoggerConfig.Builder()
.minLevel(LogLevel.DEBUG)
.addSink(testSink)
.build()
)
logger.info { "Test message" }
assertEquals(1, testSink.events.size)
assertEquals(LogLevel.INFO, testSink.events[0].level)
assertEquals("Test message", testSink.events[0].message)Both APIs share the same pipeline. LoggerFactory.install() configuration - sinks, level
filtering, and per-tag overrides - applies to Log.* calls as well as Logger calls.
| Feature | Simple Log API |
Structured Logger API |
|---|---|---|
| Setup required | No (auto-initialized) | No (auto-initialized) |
Respects LoggerFactory sinks |
✅ Yes | ✅ Yes |
| Respects level overrides | ✅ Yes | ✅ Yes |
Testable via TestSink
|
✅ Yes | ✅ Yes |
| Lazy message evaluation | ✅ Yes (via withTag/withClassTag) |
✅ Yes |
| Direct string message | ✅ Yes (Log.i("msg")) |
❌ No (lambda required) |
| Structured attributes | ❌ No | ✅ Yes |
| Set log context | ❌ No | ✅ Via LogContextHolder
|
| Carries active context | ✅ Yes (thread-local) | ✅ Yes (thread-local) |
Use Simple Log API when:
Use Structured Logger API when:
logger.info(
attrs = {
attr("method", "POST")
attr("path", "/api/users")
attr("statusCode", 201)
attr("duration", 234)
attr("ip", "192.168.1.1")
}
) { "HTTP request" }logger.debug(
attrs = {
attr("query", "SELECT * FROM users WHERE id = ?")
attr("params", listOf(123))
attr("executionTime", 45)
attr("rowsAffected", 1)
}
) { "Query executed" }logger.info(
attrs = {
attr("event", "order_created")
attr("orderId", "ORD-001")
attr("userId", 789)
attr("total", 99.99)
attr("items", 3)
}
) { "Order created successfully" }suspend fun processOrder(orderId: String, userId: Int) {
val ctx = LogContext(mapOf("orderId" to orderId, "userId" to userId))
withLogContext(ctx) {
logger.info { "Processing order" }
val payment = withContext(Dispatchers.IO) {
logger.debug { "Charging payment" } // context present on IO thread
chargePayment(orderId)
}
logger.info(attrs = { attr("paymentId", payment.id) }) { "Order complete" }
}
}@Test
fun logErrorWhenOperationFails() {
val testSink = TestSink()
LoggerFactory.install(
LoggerConfig.Builder()
.minLevel(LogLevel.DEBUG)
.addSink(testSink)
.build()
)
val logger = LoggerFactory.get("MyClass")
logger.error(attrs = { attr("operation", "save") }) { "Failed to save data" }
assertEquals(1, testSink.events.size)
val event = testSink.events[0]
assertEquals(LogLevel.ERROR, event.level)
assertEquals("Failed to save data", event.message)
assertEquals("MyClass", event.loggerName)
assertEquals("save", event.attributes["operation"])
}@Test
fun propagatesContextToNestedLogs() {
val testSink = TestSink()
LoggerFactory.install(
LoggerConfig.Builder()
.minLevel(LogLevel.DEBUG)
.addSink(testSink)
.build()
)
val logger = LoggerFactory.get("ContextTest")
LogContextHolder.withContext(LogContext(mapOf("requestId" to "req-123"))) {
logger.info { "Log 1" }
logger.info { "Log 2" }
}
testSink.events.forEach { event ->
assertEquals("req-123", event.context.values["requestId"])
}
}@Test
fun coroutineContextSurvivesSuspension() = runTest {
val testSink = TestSink()
LoggerFactory.install(
LoggerConfig.Builder()
.minLevel(LogLevel.DEBUG)
.addSink(testSink)
.build()
)
val logger = LoggerFactory.get("CoroutineTest")
withLogContext(LogContext(mapOf("requestId" to "req-123"))) {
delay(10)
logger.info { "After delay" }
}
assertEquals("req-123", testSink.events[0].context.values["requestId"])
}class FileSink(private val filename: String) : LogSink {
private val file = File(filename)
override fun emit(event: LogEvent) {
val line = "[${event.level}] ${event.loggerName}: ${event.message}\n"
file.appendText(line)
}
override fun flush() {
// ensure all data is written
}
}A production sink that reports to Firebase Crashlytics on both Android and iOS. This is the
canonical way to write a platform-specific sink in KMP: one expect factory, one actual per
target, each talking to that platform's native Crashlytics SDK. (A target that doesn't ship
Firebase can opt out by returning null - shown at the end.)
// commonMain
internal expect fun crashlyticsSink(): LogSink?// androidMain
internal class CrashlyticsLogSink : LogSink {
override fun emit(event: LogEvent) {
if (event.level.ordinal < LogLevel.WARN.ordinal) return // WARN+ only
try {
val crashlytics = FirebaseCrashlytics.getInstance()
val msg = event.message ?: ""
crashlytics.log("[${event.level}] ${event.loggerName}: $msg") // breadcrumb
val throwable = event.throwable
if (throwable != null &&
event.level.ordinal >= LogLevel.ERROR.ordinal &&
!throwable.isTransportFailure() // skip offline/timeout noise
) {
crashlytics.setCustomKey("logger.tag", event.loggerName)
crashlytics.setCustomKey("logger.level", event.level.name)
event.attributes.forEach { (k, v) -> crashlytics.setCustomKey("attr.$k", v?.toString() ?: "null") }
event.context.values.forEach { (k, v) -> crashlytics.setCustomKey("ctx.$k", v?.toString() ?: "null") }
crashlytics.recordException(throwable) // non-fatal
}
} catch (_: Throwable) {
// Crashlytics not initialized yet - a sink must never throw.
}
}
// Don't report expected offline/timeout IOExceptions as crashes.
private fun Throwable.isTransportFailure(): Boolean = this is IOException
}
internal actual fun crashlyticsSink(): LogSink? = CrashlyticsLogSink()On iOS the same contract is fulfilled against the Firebase iOS SDK (assumed already set up in your
project). Kotlin/Native Throwables aren't NSErrors, so an ERROR is recorded by wrapping it in an
FIRExceptionModel:
// iosMain
internal class CrashlyticsLogSink : LogSink {
override fun emit(event: LogEvent) {
if (event.level.ordinal < LogLevel.WARN.ordinal) return // WARN+ only
val crashlytics = FIRCrashlytics.crashlytics()
val msg = event.message ?: ""
crashlytics.log("[${event.level}] ${event.loggerName}: $msg") // breadcrumb
val throwable = event.throwable
if (throwable != null && event.level.ordinal >= LogLevel.ERROR.ordinal) {
// (apply the same transport-failure filter as Android to skip expected offline errors -
// on Apple that's a Ktor/Darwin error type, not IOException)
crashlytics.setCustomValue(event.loggerName, forKey = "logger.tag")
crashlytics.setCustomValue(event.level.name, forKey = "logger.level")
event.attributes.forEach { (k, v) -> crashlytics.setCustomValue(v?.toString() ?: "null", forKey = "attr.$k") }
event.context.values.forEach { (k, v) -> crashlytics.setCustomValue(v?.toString() ?: "null", forKey = "ctx.$k") }
val model = FIRExceptionModel.exceptionModelWithName(
name = throwable::class.qualifiedName ?: "KotlinException",
reason = throwable.message ?: msg
)
model.setStackTrace(throwable.getStackTrace().map { FIRStackFrame.stackFrameWithSymbol(it) })
crashlytics.recordExceptionModel(model) // non-fatal
}
}
}
internal actual fun crashlyticsSink(): LogSink? = CrashlyticsLogSink()Opting a platform out. If a target doesn't ship Firebase, return null there. DefaultLogSink
still routes that platform's logs to its native channel (NSLog on Apple, console on JS/Wasm,
stdout on JVM/Linux/MinGW), so nothing is lost - you simply forgo crash-reporting on that target.
The expect/actual split is exactly what lets one target integrate Crashlytics while another opts
out:
// iosMain - opt out: no Firebase on this target
internal actual fun crashlyticsSink(): LogSink? = nullKey ideas (both platforms): gate breadcrumbs to WARN+ and non-fatal exception recording to
ERROR+ with a throwable; promote structured attributes -> attr.* and context.values ->
ctx.* custom keys so they land on the Crashlytics issue; keep a sink from ever throwing (the
Android actual wraps getInstance() in try/catch for the pre-init window); and use
expect/actual to integrate - or deliberately opt out - per target.
FacebookLogSink is Android-only - it uses android.os.Bundle and AppEventsLogger - so it
lives in androidMain:
// androidMain
internal class FacebookLogSink(
private val minLevel: LogLevel = LogLevel.INFO,
private val appEventsLogger: AppEventsLogger
) : LogSink {
override fun emit(event: LogEvent) {
if (event.level < minLevel) return
val eventName = when (event.level) {
LogLevel.INFO -> "app_log_info"
LogLevel.WARN -> "app_log_warn"
LogLevel.ERROR -> "app_log_error"
LogLevel.FATAL -> "app_log_fatal"
else -> "app_log"
}
val params = Bundle().apply {
putString("logger", event.loggerName)
putString("level", event.level.name)
putString("thread", event.thread)
putString("message", event.message?.take(100)) // message is nullable
event.attributes.forEach { (k, v) ->
putString("attr_${k.safeKey()}", v?.toString()?.take(100))
}
event.context.values.forEach { (k, v) ->
putString("ctx_${k.safeKey()}", v?.toString()?.take(100))
}
event.throwable?.let {
putString("exception", it::class.simpleName)
putString("exception_message", it.message?.take(100))
}
}
appEventsLogger.logEvent(eventName, params)
}
private fun String.safeKey(): String =
lowercase().replace("[^a-z0-9_]".toRegex(), "_").take(40)
}Register it through the same expect/actual sink-factory hook used for Crashlytics - the Android
actual builds it from an AppEventsLogger (which needs a Context), and every non-Android target
returns null:
// commonMain
internal expect fun facebookSink(): LogSink?
// androidMain - construct from your app's AppEventsLogger
internal actual fun facebookSink(): LogSink? =
FacebookLogSink(appEventsLogger = AppEventsLogger.newLogger(appContext))
// iosMain / jsMain / … - no Facebook SDK on these targets
internal actual fun facebookSink(): LogSink? = nullThen add it in your installer just like the Crashlytics sink: facebookSink()?.let { builder.addSink(it) }.
Wrap another sink to scrub secrets out of the message, attributes, and context before they
reach a remote destination (Crashlytics, a log aggregator, etc.). It delegates to an inner sink after
sanitizing, so you compose it around any other sink - e.g. SanitizingSink(crashlyticsSink()).
class SanitizingSink(private val delegate: LogSink) : LogSink {
override fun emit(event: LogEvent) {
val message = event.message?.let(::scrub)
val attrs = if (event.attributes.isNotEmpty())
event.attributes.mapValues { (k, v) -> scrubAttr(k, v) } else event.attributes
val context = if (event.context.values.isNotEmpty())
event.context.copy(values = event.context.values.mapValues { (k, v) -> scrubAttr(k, v) })
else event.context
// Nothing sensitive -> forward the original event untouched.
if (message === event.message && attrs === event.attributes && context === event.context) {
delegate.emit(event); return
}
delegate.emit(event.copy(message = message, attributes = attrs, context = context))
}
override fun flush() = delegate.flush()
private fun scrubAttr(key: String, value: Any?): Any? {
val lowered = key.lowercase()
return when {
SENSITIVE_KEYS.any { lowered.contains(it) } -> REDACTED
value is String -> scrub(value)
else -> value
}
}
private fun scrub(input: String): String =
input.replace(JWT_PATTERN, REDACTED)
.replace(BEARER_PATTERN, "Bearer $REDACTED")
.replace(NAMED_VALUE_PATTERN) { "${it.groupValues[1]}=$REDACTED" }
private companion object {
const val REDACTED = "***REDACTED***"
val SENSITIVE_KEYS = setOf(
"password", "passcode", "pin", "secret", "token",
"accesstoken", "refreshtoken", "authorization", "otp", "apikey", "api_key"
)
val JWT_PATTERN = Regex("""\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b""")
val BEARER_PATTERN = Regex("""Bearer\s+[A-Za-z0-9._-]+""", RegexOption.IGNORE_CASE)
val NAMED_VALUE_PATTERN = Regex(
"""(?i)\b(password|passcode|pin|secret|token|access_?token|refresh_?token|authorization|otp|api_?key)\s*=\s*[^\s,;&]+"""
)
}
}It matches sensitive keys by substring (so accessToken, X-Api-Key are caught) and scrubs
sensitive values in free-text messages via the JWT, Bearer …, and name=value patterns.
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
LoggerFactory.install(
LoggerConfig.Builder()
.minLevel(if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO)
.addSink(DefaultLogSink())
.build()
)
}
}fun initializeApp() {
LoggerFactory.install(
LoggerConfig.Builder()
.minLevel(LogLevel.DEBUG)
.addSink(DefaultLogSink())
.build()
)
}fun main() {
LoggerFactory.install(
LoggerConfig.Builder()
.minLevel(LogLevel.DEBUG)
.addSink(DefaultLogSink())
.build()
)
// your application code
}These patterns are distilled from a production Kotlin Multiplatform app that uses KMP Logger across
its networking, caching, analytics, and feature modules. They combine the pieces above -
LoggerConfig, custom sinks, per-tag overrides, and coroutine context - into a working setup.
Centralize configuration in a single installer, called once at startup before any Log.* call and
before your DI container (code logs during DI setup itself, so the logger must be a process-global,
not a DI singleton):
object LoggerInitializer {
fun install(isDebug: Boolean) {
Log.setDefaultTag("MyApp")
val builder = LoggerConfig.Builder()
.minLevel(if (isDebug) LogLevel.VERBOSE else LogLevel.INFO)
.addSink(DefaultLogSink()) // Logcat / NSLog / stdout / console
if (isDebug) {
builder.addSink(
ConsoleSink(
LogFormatters.pretty(
showEmoji = true, includeTimestamp = true,
includeThread = true, prettyPrint = true
)
)
)
}
// Crashlytics wrapped in the sanitizer so secrets never reach Firebase.
crashlyticsSink()?.let { builder.addSink(SanitizingSink(it)) }
if (!isDebug) { // Quiet chatty modules in release
builder.override("Network", LogLevel.WARN)
.override("WebSocket", LogLevel.WARN)
.override("Ktor", LogLevel.WARN)
}
LoggerFactory.install(builder.build())
}
}Wire it as the very first thing on each platform entry point:
// Android - Application.onCreate()
FirebaseApp.initializeApp(this)
LoggerInitializer.install(isDebug = BuildConfig.DEBUG) // BEFORE startKoin / any Log usage
startKoin { /* ... */ }// iOS - bootstrap, once per process
LoggerInitializer.install(isDebug = isDebugBuild())
startKoin { /* ... */ }Route analytics through the same log pipeline in debug so you can see events without shipping them, and swap in the real SDK for release via DI:
class StubAnalyticsHelper : AnalyticsHelper { // debug: log only, never hits Firebase
override fun logEvent(event: AnalyticsEvent) {
val params = event.params.joinToString(", ") { "${it.key}=${it.value}" }
Log.d("event '${event.type}' { $params }", tag = "Analytics")
}
override fun setUserId(userId: String?) =
Log.d("setUserId(${userId ?: "null"})", tag = "Analytics")
override fun setUserProperty(name: String, value: String?) =
Log.d("userProperty $name=${value ?: "null"}", tag = "Analytics")
}
// DI: pick implementation by build type
single<AnalyticsHelper> { if (isDebugBuild()) StubAnalyticsHelper() else FirebaseAnalyticsHelper() }Because it shares the pipeline, the release minLevel (or a .override("Analytics", LogLevel.WARN))
silences analytics tracing in production with no extra flag.
Attach the call's identity once, and every log line from that logger - plus the ctx.* keys on
any crash report - carries it automatically:
suspend fun <T> handle(response: HttpResponse): T {
val log = LoggerFactory.get("Network").withContext(
"http.method" to response.request.method.value,
"http.url" to response.request.url.toString(),
"http.status" to response.status.value,
)
return parseBody(response, log) // every log.* call carries the http.* context
}Binding works on every target. If you need the context to reach loggers you don't pass it to -
for example code deeper in the stack you don't own - use withLogContext and bind at the log
site with withActiveLogContext(), or rely on ambient visibility on JVM/Android:
withLogContext(LogContext(values = mapOf("http.url" to url))) {
val log = LoggerFactory.get("Network").withActiveLogContext()
log.info { "Request finished" }
}Pair it with transport-aware levels so expected offline/timeout failures are WARN (a visible
breadcrumb, not reported) while real faults are ERROR (recorded by the Crashlytics sink):
fun logError(tag: String, message: String, e: Exception?, type: ErrorType) {
if (type == ErrorType.NETWORK || type == ErrorType.TIMEOUT)
Log.w(message, tag = tag, throwable = e) // expected - breadcrumb only
else
Log.e(message, tag = tag, throwable = e) // real fault - non-fatal in Crashlytics
}This is the WARN-vs-ERROR contract the Crashlytics sink above relies on (ERROR+ with a throwable ⇒
recordException). You can also forward a third-party client's own logs into the pipeline - e.g. pipe
Ktor's logger into Log.d(message, tag = "Ktor") so it obeys the same sinks and overrides.
Three idioms, each for a different layer:
// 1. UI / ViewModels - the class name is the tag
class LoginViewModel {
private val log = Log.withClassTag<LoginViewModel>() // tag == "LoginViewModel"
fun onError(t: Throwable) = log.e("Login failed", throwable = t)
}
// 2. Infra modules - a shared constant tag, lazy lambda message, structured attrs
private val log = LoggerFactory.get("Cache")
log.debug(attrs = { attr("op", "read"); attr("key", key) }) { "READ" }
log.error(throwable = e, attrs = { attr("op", "read"); attr("key", key) }) { "Cache READ failed" }
// 3. Centralize tag strings so call sites and override() can't drift
const val NETWORK_TAG = "Network"
LoggerConfig.Builder().override(NETWORK_TAG, LogLevel.WARN) // ... and Log.d(msg, tag = NETWORK_TAG)If a library ships its own log-event type, map it onto Log.* so its output flows through your
sinks, thresholds, and overrides - no competing sink, no dependency on the logger from that module:
val bridge = SomeSdkLogger { event ->
when (event.level) {
SdkLevel.VERBOSE -> Log.v(event.message, event.tag)
SdkLevel.DEBUG -> Log.d(event.message, event.tag)
SdkLevel.INFO -> Log.i(event.message, event.tag)
SdkLevel.WARN -> Log.w(event.message, event.tag, event.throwable)
SdkLevel.ERROR -> Log.e(event.message, event.tag, event.throwable)
}
}Now the SDK's logs land in Logcat/NSLog in debug and your sanitized Crashlytics sink in release, tagged under its own name.
// Good
logger.debug { "Cache size: ${cache.size}" }
logger.info { "User logged in successfully" }
logger.warn { "API rate limit approaching" }
logger.error { "Failed to connect to database" }
// Bad
logger.error { "User clicked button" } // not an error
logger.debug { "Critical system failure" } // use ERROR or FATAL// Good - lambda only evaluated if level is enabled
logger.debug { "User: ${user.toDetailedString()}" }
// Bad - always evaluates regardless of level
logger.debug("User: ${user.toDetailedString()}")// Good - machine-readable, searchable
logger.info(
attrs = {
attr("userId", userId)
attr("duration", duration)
}
) { "Request completed" }
// Bad - hard to parse programmatically
logger.info { "Request completed for user $userId in ${duration}ms" }// Good - set once for the whole scope
LogContextHolder.withContext(LogContext(mapOf("requestId" to requestId))) {
logger.info { "Starting request" }
processRequest()
logger.info { "Request completed" }
}
// Bad - repeating the same field everywhere
logger.info(attrs = { attr("requestId", requestId) }) { "Starting request" }
logger.info(attrs = { attr("requestId", requestId) }) { "Request completed" }// Good - safe across thread hops and suspension
withLogContext(LogContext(mapOf("requestId" to requestId))) {
withContext(Dispatchers.IO) { fetchData() }
}
// Risky on JVM/Android - context lost after thread switch
LogContextHolder.withContext(LogContext(mapOf("requestId" to requestId))) {
withContext(Dispatchers.IO) { fetchData() } // context may be missing here
}// Good
logger.info(attrs = { attr("userId", userId) }) { "User authenticated" }
// Bad
logger.info { "User logged in with password: $password" }minLevel to reduce overhead in productionLoggerFactory.install(), the default minimum level is VERBOSE - all logs
should appear by defaultLoggerFactory.install(), verify minLevel is not filtering your logs.override() - they take precedence over minLevel
// The logger name must EXACTLY match the override key
val config = LoggerConfig.Builder()
.override("MyLogger", LogLevel.ERROR) // ← exact string
.build()
val logger = LoggerFactory.get("MyLogger") // ← must matchYou must call LogContextHolder.withContext() - simply creating a LogContext object does nothing:
// Correct
LogContextHolder.withContext(context) {
logger.info { "Has context" }
}
// Wrong - context object exists but is never applied
val context = LogContext(mapOf("key" to "value"))
logger.info { "No context" }If your context disappears after delay() or a thread switch on JVM/Android, you are using the
synchronous LogContextHolder.withContext inside a coroutine. Switch to withLogContext from the
logger-coroutines module:
// Before (context lost after thread switch on JVM/Android)
LogContextHolder.withContext(ctx) {
withContext(Dispatchers.IO) { ... }
}
// After (context always present)
withLogContext(ctx) {
withContext(Dispatchers.IO) { ... }
}Apache License 2.0 - see the LICENSE file for details.
Thanks for the ⭐ - it means a lot!