A lightweight, structured logging library for Kotlin Multiplatform. Supports Android, iOS, macOS, watchOS, tvOS, JVM, JS (Node.js & Browser), Wasm/JS, Linux, and MinGW.
- Features
- Installation
- Simple Log API
- Structured Logging Quick Start
- Core Concepts
- Configuration
- Log Context
- Coroutine Support
- Log Formatters
- Available Sinks
- Simple vs Structured Logging
- Usage Examples
- Testing
- Advanced Topics
- Platform-Specific Setup
- Best Practices
- Performance Considerations
- Troubleshooting
- Contributing
- License
- Two APIs - Simple
Log.*for quick debugging; structuredLoggerfor production use - Log Levels -
VERBOSE,DEBUG,INFO,WARN,ERROR,FATAL,OFF - Structured Logging - Attach typed key-value attributes to any log event
- Context Propagation - Thread-local context on JVM/Android,
NSThreaddictionary on Apple, scoped save/restore everywhere else - Coroutine-Aware Context - Optional
logger-coroutinesmodule withwithLogContextthat propagatesLogContextsafely across suspension points and thread hops on all platforms - Lazy Evaluation - Message lambda is never evaluated when the event would be filtered out
- Real Timestamps - Epoch milliseconds captured on all platforms
- Thread Names - Captured natively on Android, JVM, and Apple;
"main"elsewhere - Platform-Native Output - Logcat on Android,
NSLogon Apple,consoleon JS/Wasm,stdouton JVM/Linux/MinGW - Configurable Pipeline - Per-tag level overrides, multiple sinks, custom formatters
- Built-in Formatters - Default (human-readable), JSON (log aggregators), Compact, and Pretty
- Testable - Built-in
TestSinkfor asserting log output in unit tests - Extensible - Implement
LogSinkto 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:
- The coroutine is pinned to a single thread (e.g.
Dispatchers.Main), or - No thread-migration occurs in the block.
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 -
android.util.Log(Logcat) - Apple -
NSLog - JVM / Linux / MinGW -
stdout - JS / Wasm -
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:
- Quick debugging during development
- Prototyping or simple scripts
- No structured data needed
Use Structured Logger API when:
- Production applications
- Lazy evaluation matters (hot paths, expensive message construction)
- Need structured key-value attributes
- Want to scope context to a block
- Unit testing specific log output
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" }- Lazy evaluation - Always use lambda syntax to avoid unnecessary string construction
- Level filtering - Set an appropriate
minLevelto reduce overhead in production - Async sinks - For high-throughput scenarios, wrap your sink in an async dispatcher
- Attribute count - Attributes are powerful but avoid attaching dozens to every event
- If you never called
LoggerFactory.install(), the default minimum level isVERBOSE- all logs should appear by default - If you did call
LoggerFactory.install(), verifyminLevelis not filtering your logs - Check per-logger overrides with
.override()- they take precedence overminLevel - Ensure at least one sink is configured
// 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) { ... }
}- Found a bug or have a feature idea? Open an issue
- Want to contribute code? Review CONTRIBUTING.md before opening a pull request
- Help others discover the library by sharing it or giving the repo a ⭐
Apache License 2.0 - see the LICENSE file for details.
Thanks for the ⭐ - it means a lot!

