
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 else"main" elsewhereNSLog on Apple, console on JS/Wasm, stdouton
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.3.0")
}For Android-only apps:
// build.gradle.kts (androidMain)
androidMain.dependencies {
implementation("io.github.shivathapaa:logger-android:1.3.0")
}For 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
}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" }@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"])
}
}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
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" }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 else"main" elsewhereNSLog on Apple, console on JS/Wasm, stdouton
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.3.0")
}For Android-only apps:
// build.gradle.kts (androidMain)
androidMain.dependencies {
implementation("io.github.shivathapaa:logger-android:1.3.0")
}For 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
}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" }@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"])
}
}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
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" }Apache License 2.0 - see the LICENSE file for details.
Thanks for the ⭐ - it means a lot!