
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, watchOS, tvOS, 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
NSThread dictionary on Apple,
scoped save/restore everywhere elselogger-coroutines module with withLogContext that
propagates LogContext safely across suspension points and thread hops on all platforms"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:1.4.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:1.4.0")
implementation("io.github.shivathapaa:logger-coroutines:1.4.0")
}For Android-only apps, platform-specific artifacts are also available:
// build.gradle.kts (androidMain)
androidMain.dependencies {
implementation("io.github.shivathapaa:logger-android:1.4.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 safe LogContext
propagation across suspension points on all platforms.
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.
The logger-coroutines module provides withLogContext, which solves this correctly on every
platform:
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" }
}
}| Platform | Mechanism |
|---|---|
| JVM / Android |
LogContextElement implements ThreadContextElement. The coroutines dispatcher calls updateThreadContext/restoreThreadContext on every thread switch automatically. |
| iOS / macOS / Apple | Single-threaded. Context is set directly on LogContextHolder for the duration of the block and restored in finally. |
| JS / WasmJS / Linux / MinGW | Single-threaded. Same approach as Apple. |
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 does not solve the thread-migration problem on JVM/Android - context is
stored in a ThreadLocal and will be lost if the coroutine resumes on a different thread.
Use withSuspendingContext only when:
Dispatchers.Main), or
For all other cases, use withLogContext from logger-coroutines.
// Safe on single-threaded dispatchers (Main, iOS, JS, etc.)
LogContextHolder.withSuspendingContext(ctx) {
delay(100)
logger.info { "Context is present" }
}| Scenario | API |
|---|---|
| Non-coroutine code | LogContextHolder.withContext |
| Coroutine, single-threaded dispatcher only (Main, JS) | LogContextHolder.withSuspendingContext |
| Coroutine, any dispatcher / thread hops (IO, Default) |
withLogContext from logger-coroutines
|
| Fixed context on a whole scope |
LogContextElement from logger-coroutines
|
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
}
}class FirebaseLogSink(
private val minLevel: LogLevel = LogLevel.WARN,
private val formatter: LogEventFormatter = LogFormatters.json(false),
private val crashlytics: FirebaseCrashlytics = FirebaseCrashlytics.getInstance()
) : LogSink {
override fun emit(event: LogEvent) {
if (event.level < minLevel) return
attachContext(event)
val message = formatter.format(event)
when (event.level) {
LogLevel.ERROR,
LogLevel.FATAL -> {
val throwable = event.throwable ?: LoggedException(message)
crashlytics.recordException(throwable)
}
else -> crashlytics.log(message)
}
}
private fun attachContext(event: LogEvent) {
event.context.values.forEach { (key, value) ->
crashlytics.setCustomKey(
key.take(40),
value?.toString()?.take(100) ?: "null"
)
}
}
private class LoggedException(message: String) : RuntimeException(message)
}
// Provide a no-op implementation for non-Android targetsclass 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))
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)
}
// Provide a no-op implementation for non-Android targetsclass SanitizingSink(private val delegate: LogSink) : LogSink {
private val sensitiveKeys = setOf("password", "apiKey", "token")
override fun emit(event: LogEvent) {
val sanitized = event.attributes.mapValues { (key, value) ->
if (key in sensitiveKeys) "***REDACTED***" else value
}
delegate.emit(event.copy(attributes = sanitized))
}
}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
}// 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, watchOS, tvOS, 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
NSThread dictionary on Apple,
scoped save/restore everywhere elselogger-coroutines module with withLogContext that
propagates LogContext safely across suspension points and thread hops on all platforms"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:1.4.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:1.4.0")
implementation("io.github.shivathapaa:logger-coroutines:1.4.0")
}For Android-only apps, platform-specific artifacts are also available:
// build.gradle.kts (androidMain)
androidMain.dependencies {
implementation("io.github.shivathapaa:logger-android:1.4.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 safe LogContext
propagation across suspension points on all platforms.
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.
The logger-coroutines module provides withLogContext, which solves this correctly on every
platform:
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" }
}
}| Platform | Mechanism |
|---|---|
| JVM / Android |
LogContextElement implements ThreadContextElement. The coroutines dispatcher calls updateThreadContext/restoreThreadContext on every thread switch automatically. |
| iOS / macOS / Apple | Single-threaded. Context is set directly on LogContextHolder for the duration of the block and restored in finally. |
| JS / WasmJS / Linux / MinGW | Single-threaded. Same approach as Apple. |
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 does not solve the thread-migration problem on JVM/Android - context is
stored in a ThreadLocal and will be lost if the coroutine resumes on a different thread.
Use withSuspendingContext only when:
Dispatchers.Main), or
For all other cases, use withLogContext from logger-coroutines.
// Safe on single-threaded dispatchers (Main, iOS, JS, etc.)
LogContextHolder.withSuspendingContext(ctx) {
delay(100)
logger.info { "Context is present" }
}| Scenario | API |
|---|---|
| Non-coroutine code | LogContextHolder.withContext |
| Coroutine, single-threaded dispatcher only (Main, JS) | LogContextHolder.withSuspendingContext |
| Coroutine, any dispatcher / thread hops (IO, Default) |
withLogContext from logger-coroutines
|
| Fixed context on a whole scope |
LogContextElement from logger-coroutines
|
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
}
}class FirebaseLogSink(
private val minLevel: LogLevel = LogLevel.WARN,
private val formatter: LogEventFormatter = LogFormatters.json(false),
private val crashlytics: FirebaseCrashlytics = FirebaseCrashlytics.getInstance()
) : LogSink {
override fun emit(event: LogEvent) {
if (event.level < minLevel) return
attachContext(event)
val message = formatter.format(event)
when (event.level) {
LogLevel.ERROR,
LogLevel.FATAL -> {
val throwable = event.throwable ?: LoggedException(message)
crashlytics.recordException(throwable)
}
else -> crashlytics.log(message)
}
}
private fun attachContext(event: LogEvent) {
event.context.values.forEach { (key, value) ->
crashlytics.setCustomKey(
key.take(40),
value?.toString()?.take(100) ?: "null"
)
}
}
private class LoggedException(message: String) : RuntimeException(message)
}
// Provide a no-op implementation for non-Android targetsclass 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))
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)
}
// Provide a no-op implementation for non-Android targetsclass SanitizingSink(private val delegate: LogSink) : LogSink {
private val sensitiveKeys = setOf("password", "apiKey", "token")
override fun emit(event: LogEvent) {
val sanitized = event.attributes.mapValues { (key, value) ->
if (key in sensitiveKeys) "***REDACTED***" else value
}
delegate.emit(event.copy(attributes = sanitized))
}
}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
}// 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!