Last Week
The presentation layer hookup to the backend is complete but riddled with a perplexing error: database operations succeed but the UI reports failure. This phantom error led to implementing proper logging infrastructure – enter Kermit the Log from TouchLabs. The implementation journey also revealed important lessons about when subagents help versus hinder development progress.
The Phantom Error and the Need for Logging
The error manifests in a maddening way: add something through the UI, the database operation succeeds, the record appears in the database, the operation returns successful – yet the UI stubbornly displays “Operation failed. Please try again.” Somewhere in the layers between Supabase and the UI, success transforms into failure.
Debugging asynchronous operations with breakpoints in a reactive UI is like performing surgery with oven mitts – theoretically possible but practically useless. Print statements were the temporary bandage, but constantly adding and removing them to satisfy test requirements became its own special form of development hell. Enter Kermit the Log.
What does it mean in English?
Imagine your car’s check engine light comes on, but when the mechanic checks, the engine is running perfectly. To figure out what’s happening, you need a detailed log of every signal between the engine and the dashboard. That’s what Kermit does for apps – it records what’s happening at each layer so you can track down where good news turns into bad news.
Nerdy Details
Kermit the Log: TouchLabs’ Gift to KMP Developers
Kermit (yes, named after the frog who sits on a log) is TouchLabs’ Kotlin Multiplatform logging library that solves a fundamental problem: how to write log statements once and have them work correctly across Android (Logcat), iOS (OSLog), JavaScript (console), and JVM platforms.
// Basic Kermit setup in commonMain
implementation("co.touchlab:kermit:2.0.4")
implementation("co.touchlab:kermit-crashlytics:2.0.4") // Optional crash reporting
// Simple initialization
class MyApplication {
init {
// Default configuration writes to platform-specific loggers
Logger.setLogWriters(platformLogWriter())
Logger.setMinSeverity(Severity.Debug)
}
}
The Architecture of Kermit
Kermit’s elegance lies in its composable architecture:
// Core components
sealed class Severity {
object Verbose : Severity()
object Debug : Severity()
object Info : Severity()
object Warn : Severity()
object Error : Severity()
object Assert : Severity()
}
interface LogWriter {
fun log(severity: Severity, message: String, tag: String, throwable: Throwable?)
}
class Logger(
private val logWriters: List<LogWriter>,
private val tag: String = "Kermit",
private val minSeverity: Severity = Severity.Info
) {
fun v(message: String) = log(Severity.Verbose, message)
fun d(message: String) = log(Severity.Debug, message)
fun i(message: String) = log(Severity.Info, message)
fun w(message: String) = log(Severity.Warn, message)
fun e(message: String, throwable: Throwable? = null) =
log(Severity.Error, message, throwable)
}
Platform-Specific Implementations
The magic happens in the platform-specific LogWriter implementations:
// Android implementation
class LogcatWriter : LogWriter {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
val priority = when (severity) {
Severity.Verbose -> Log.VERBOSE
Severity.Debug -> Log.DEBUG
Severity.Info -> Log.INFO
Severity.Warn -> Log.WARN
Severity.Error -> Log.ERROR
Severity.Assert -> Log.ASSERT
}
if (throwable != null) {
Log.println(priority, tag, message)
Log.println(priority, tag, Log.getStackTraceString(throwable))
} else {
Log.println(priority, tag, message)
}
}
}
// iOS implementation
class OSLogWriter : LogWriter {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
val osLogType = when (severity) {
Severity.Verbose -> OSLogType.DEBUG
Severity.Debug -> OSLogType.DEBUG
Severity.Info -> OSLogType.INFO
Severity.Warn -> OSLogType.DEFAULT
Severity.Error -> OSLogType.ERROR
Severity.Assert -> OSLogType.FAULT
}
os_log(message, log: OSLog.default, type: osLogType)
throwable?.let {
os_log(it.toString(), log: OSLog.default, type: osLogType)
}
}
}
Implementing Kermit in Clean Architecture
For the Shokken app with its clean architecture, Kermit needed to be integrated across all layers while respecting boundaries:
// Domain layer - logger interface
interface AppLogger {
fun d(tag: String, message: String)
fun i(tag: String, message: String)
fun w(tag: String, message: String)
fun e(tag: String, message: String, throwable: Throwable? = null)
}
// Data layer - Kermit implementation
class KermitAppLogger : AppLogger {
private val logger = Logger(
logWriters = platformLogWriter(),
tag = "Shokken"
)
override fun d(tag: String, message: String) {
logger.withTag(tag).d(message)
}
override fun i(tag: String, message: String) {
logger.withTag(tag).i(message)
}
override fun w(tag: String, message: String) {
logger.withTag(tag).w(message)
}
override fun e(tag: String, message: String, throwable: Throwable?) {
logger.withTag(tag).e(message, throwable)
}
}
// Dependency injection with Koin
val loggingModule = module {
single<AppLogger> { KermitAppLogger() }
}
Strategic Logging Points for the Phantom Error
To catch where success becomes failure, logging needed to be strategic:
// Repository layer
class GuestRepositoryImpl(
private val supabaseClient: SupabaseClient,
private val logger: AppLogger
) : GuestRepository {
override suspend fun addGuest(guest: Guest): Result<Guest> {
logger.d(TAG, "Adding guest: ${guest.name}")
return try {
val dto = guest.toDto()
logger.d(TAG, "Converted to DTO: $dto")
val response = supabaseClient
.from("guests")
.insert(dto)
.decodeSingle<GuestDto>()
logger.i(TAG, "Database insert successful: ${response.id}")
val domainGuest = response.toDomain()
logger.d(TAG, "Converted to domain: $domainGuest")
Result.success(domainGuest)
} catch (e: Exception) {
logger.e(TAG, "Database insert failed", e)
Result.failure(e)
}
}
companion object {
private const val TAG = "GuestRepository"
}
}
// Use case layer
class AddGuestUseCase(
private val repository: GuestRepository,
private val logger: AppLogger
) {
suspend operator fun invoke(input: GuestInput): DomainResult<Guest> {
logger.d(TAG, "AddGuestUseCase invoked with: $input")
val validationResult = validateInput(input)
if (validationResult is DomainResult.Error) {
logger.w(TAG, "Validation failed: ${validationResult.message}")
return validationResult
}
val guest = Guest(
name = input.name,
phone = normalizePhone(input.phone),
partySize = input.partySize
)
logger.d(TAG, "Created guest entity: $guest")
return repository.addGuest(guest).fold(
onSuccess = {
logger.i(TAG, "Guest added successfully: ${it.id}")
DomainResult.Success(it)
},
onFailure = { error ->
logger.e(TAG, "Failed to add guest", error)
DomainResult.Error("Failed to add guest: ${error.message}")
}
)
}
companion object {
private const val TAG = "AddGuestUseCase"
}
}
// ViewModel layer
class HomeScreenModel(
private val addGuestUseCase: AddGuestUseCase,
private val logger: AppLogger
) : ScreenModel, ContainerHost<HomeState, HomeSideEffect> {
fun onAddGuest(input: GuestInput) = intent {
logger.d(TAG, "onAddGuest called with: $input")
reduce { state.copy(isLoading = true) }
when (val result = addGuestUseCase(input)) {
is DomainResult.Success -> {
logger.i(TAG, "Guest added to state: ${result.data.id}")
reduce {
state.copy(
isLoading = false,
guests = state.guests + result.data
)
}
postSideEffect(HomeSideEffect.ShowSuccess)
}
is DomainResult.Error -> {
logger.e(TAG, "Error in UI: ${result.message}")
reduce { state.copy(isLoading = false) }
postSideEffect(HomeSideEffect.ShowError(result.message))
}
}
}
companion object {
private const val TAG = "HomeScreenModel"
}
}
Production Configuration with Build Variants
Kermit shines in its ability to configure logging per build variant:
// BuildConfig-based configuration
object LoggingConfig {
fun initialize() {
val writers = mutableListOf<LogWriter>()
if (BuildConfig.DEBUG) {
// Development: All logs to platform loggers
writers.add(platformLogWriter())
Logger.setMinSeverity(Severity.Verbose)
} else {
// Production: Only errors to crash reporting
writers.add(CrashlyticsLogWriter())
Logger.setMinSeverity(Severity.Error)
// Strip verbose/debug with R8/ProGuard
}
// Optional: File logging for debug builds
if (BuildConfig.ENABLE_FILE_LOGGING) {
writers.add(RollingFileLogWriter(
fileName = "shokken.log",
maxFileSize = 5 * 1024 * 1024, // 5MB
maxFiles = 3
))
}
Logger.setLogWriters(writers)
}
}
// R8 rules to strip logs in release
-assumenosideeffects class co.touchlab.kermit.Logger {
public void v(...);
public void d(...);
}
Testing with Kermit
Testing with proper logging provides invaluable debugging information:
class TestLogWriter : LogWriter {
val logs = mutableListOf<LogEntry>()
data class LogEntry(
val severity: Severity,
val message: String,
val tag: String,
val throwable: Throwable?
)
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
logs.add(LogEntry(severity, message, tag, throwable))
}
fun assertLogged(severity: Severity, partialMessage: String): Boolean {
return logs.any {
it.severity == severity && it.message.contains(partialMessage)
}
}
}
class AddGuestUseCaseTest {
private val testLogger = TestLogWriter()
private val logger = Logger(logWriters = listOf(testLogger))
@Test
fun `logs validation failure`() = runTest {
val useCase = AddGuestUseCase(mockRepository, logger)
val result = useCase(GuestInput(name = "", phone = "123"))
assertTrue(result is DomainResult.Error)
assertTrue(testLogger.assertLogged(Severity.Warn, "Validation failed"))
}
}
The Subagent Lesson: When AI Helps and When It Doesn’t
The implementation revealed a crucial insight about AI-assisted development. Subagents excel at routine operations where patterns are established:
// Good subagent use case - copying existing patterns
"Copy the WaitlistForm to create a GuestForm with similar validation"
// Bad subagent use case - exploratory implementation
"Implement Kermit logging throughout the codebase"
The medical analogy is apt: subagents are like specialized surgeons who excel at specific procedures. But for exploratory surgery where you don’t know what you’ll find, you need the master surgeon (master agent) who can adapt in real-time.
The ping-pong problem manifested when the subagent kept alternating between two approaches:
// Iteration 1: Subagent tries interface approach
interface Logger {
fun log(message: String)
}
// Iteration 2: Realizes Kermit has its own Logger, switches
import co.touchlab.kermit.Logger
// Iteration 3: Conflicts arise, switches back to interface
interface AppLogger { // Renamed to avoid conflict
fun log(message: String)
}
// Iteration 4: Back to Kermit... and so on
The solution was direct guidance with the master agent:
- Cancel operations mid-execution when going wrong
- Provide explicit corrections
- Sometimes manually fix code and tell Claude to move on
- Use Opus for longer context without degradation
Performance Considerations
Logging can impact performance if not carefully managed:
// Bad: String concatenation happens even if log level filters it out
logger.d("Processing items: ${items.joinToString()}")
// Good: Use lazy evaluation
logger.d { "Processing items: ${items.joinToString()}" }
// Kermit's lazy message support
inline fun Logger.d(crossinline message: () -> String) {
if (minSeverity <= Severity.Debug) {
d(message())
}
}
// Conditional compilation with expect/actual
expect fun platformSpecificLog(message: String)
// Android actual - can be stripped by R8
actual fun platformSpecificLog(message: String) {
if (BuildConfig.DEBUG) {
Log.d("Platform", message)
}
}
Integration with Crash Reporting
Kermit’s crash reporting integration provides production insights:
class CrashlyticsLogWriter : LogWriter {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
val formattedMessage = "[$tag] $message"
when (severity) {
Severity.Error, Severity.Assert -> {
if (throwable != null) {
FirebaseCrashlytics.getInstance().recordException(throwable)
}
FirebaseCrashlytics.getInstance().log(formattedMessage)
}
Severity.Warn -> {
FirebaseCrashlytics.getInstance().log(formattedMessage)
}
else -> {
// Don't send verbose/debug/info to Crashlytics
}
}
}
}
Next Week
Armed with comprehensive logging, the phantom error’s days are numbered. The logs will reveal exactly where database success morphs into UI failure. Once that mystery is solved, focus shifts to UI polish – but first, the truth must be found in the logs.
The lesson about subagents versus direct agent interaction will shape future development approaches: use subagents for the routine, keep the master agent for the exploratory.