Skip to content

Log Monitor

View your app's logs without ADB or platform-specific consoles. Level filters, full-text search, copyable stacktraces, and metadata-aware entries. Built-in bridge for Kermit by Touchlab; any logging library plugs in via the LogCollector interface.

Platforms

Android iOS Desktop Web JS Web Wasm

Features

  • Color-coded levels — V / D / I / W / E / A badges adapt to the active theme.
  • Level filter chips — toggle each level on / off independently.
  • Full-text search — filter by tag or message text.
  • Error counter — at-a-glance count of ERROR/ASSERT entries.
  • Copyable detail view — message, stacktrace, timestamp, optional metadata.
  • Multi-library bridges — Kermit out of the box; Timber, SLF4J, java.util.logging, anything that exposes a writer via the simple LogCollector interface.
  • Configurable retention — auto-prune by Duration. Total entries capped at 1 000.

Modules

Module Purpose
:plugins:log-monitor:api Core data model, LogMonitorStore, LogCollector interface.
:plugins:log-monitor:ui Compose UI + LogMonitorPlugin (the SidekickPlugin impl).
:plugins:log-monitor:kermit Kermit LogWriter bridge.
:plugins:log-monitor:noop Release stub for all three above — same FQNs, empty bodies. No SQLDelight database, LogMonitorLogWriter.log() discards entries, LogMonitorStore.record() is a no-op. Swap in via releaseImplementation on Android or a build property on other targets. See Release builds.

Setup

1. Add dependencies

// build.gradle.kts
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(platform("dev.parez.sidekick:bom:2026.05.17"))
            // `compileOnly` keeps the real jars off Android release's runtime
            // classpath, where they would collide with the noop variant.
            compileOnly("dev.parez.sidekick:log-monitor-ui")
            compileOnly("dev.parez.sidekick:log-monitor-kermit") // optional — Kermit bridge
        }
    }
}

dependencies {
    implementation(platform("dev.parez.sidekick:bom:2026.05.17"))
    debugImplementation("dev.parez.sidekick:shell")
    releaseImplementation("dev.parez.sidekick:noop")
    // Release Android: swap the recording trio (api + ui + kermit) for the
    // single `log-monitor-noop` module, which exposes the same FQNs but strips
    // SQLDelight and makes record / log calls empty.
    debugImplementation("dev.parez.sidekick:log-monitor-ui")
    debugImplementation("dev.parez.sidekick:log-monitor-kermit")
    releaseImplementation("dev.parez.sidekick:log-monitor-noop")
}

debugImplementation / releaseImplementation are Android-only. For Desktop / iOS / JS / Wasm, see Release builds › Non-Android targets for the property-gated swap recipe.

Omit log-monitor-kermit if you're not using Kermit; see Advanced › Custom logging library.

For multi-module apps where the calling code is in a KMP library, see Installation › Multi-module KMP app for the compileOnly / per-target split.

2. Wire into Sidekick

@Composable
fun App() {
    val logPlugin = remember { LogMonitorPlugin() }
    var sidekickVisible by remember { mutableStateOf(false) }

    Box(Modifier.fillMaxSize()) {
        MyAppContent()

        SmallFloatingActionButton(
            onClick = { sidekickVisible = true },
            modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
        ) {
            Icon(Icons.Default.BugReport, contentDescription = "Open Sidekick")
        }

        AnimatedVisibility(visible = sidekickVisible, /* enter, exit */) {
            Sidekick(
                plugins = listOf(logPlugin),
                actions = {
                    IconButton(onClick = { sidekickVisible = false }) {
                        Icon(Icons.Default.Close, contentDescription = "Close")
                    }
                },
            )
        }
    }
}

3. Install the Kermit bridge

The bridge forwards every Kermit log call into the Log Monitor. Wire it once at app startup — typically in your App composable or Application.onCreate():

val logPlugin = remember {
    LogMonitorPlugin().also {
        Logger.setLogWriters(
            platformLogWriter(),
            LogMonitorLogWriter(),       // defaults to the LogMonitorStore singleton
        )
    }
}

All Logger.d(...), Logger.i(...), Logger.e(...) calls now appear in the Sidekick log panel automatically.

Alternative: configure a custom Logger instance

If you already build a Kermit Logger in DI with mutableLoggerConfigInit(...) — typical when you have multiple writers (Crashlytics, an in-app DB, etc.) — add LogMonitorLogWriter() to the same writer list:

val config = mutableLoggerConfigInit(
    platformLogWriter(),
    DatabaseLogWriter(...),
    CrashlyticsLogWriter(),
    LogMonitorLogWriter(),
)
val logger = Logger(config = config, tag = "MyApp")

Conditional bridge in release builds

If you use the releaseImplementation("dev.parez.sidekick:log-monitor-noop") swap shown above, no further gating is neededLogMonitorLogWriter() resolves to the noop's empty subclass in release builds, every call to log(...) discards the entry, and LogMonitorStore.record(...) is also a no-op. Verified by inspecting the merged release DEX: LogMonitorDatabase symbols are absent entirely.

If you can't use the noop swap (e.g. you ship the real log-monitor-kermit in release for parity testing, or you're on a target without an automatic swap), gate the writer on build type instead:

val writers = buildList {
    add(platformLogWriter())
    add(CrashlyticsLogWriter())
    if (!appProperties.isRelease) add(LogMonitorLogWriter())
}
Logger(config = mutableLoggerConfigInit(*writers.toTypedArray()), tag = "MyApp")

Configuration

import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.days

LogMonitorPlugin(retentionPeriod = 24.hours)
Example Behaviour
1.hours (default) Keep the last hour of entries.
24.hours Keep the last day.
7.days Keep the last week.
Duration.INFINITE Never prune by age.

The store also caps total entries at 1 000 (oldest pruned first), regardless of retention.

UI

The panel adapts to the available width:

Width Layout
< 600 dp Single pane — tap an entry to see its detail.
600 – 840 dp Two panes at 40 / 60 split.
≥ 840 dp Two panes — list fixed at 360 dp.

List view features:

  • Color-coded level badges (V = gray, D = green, I = blue, W = amber, E = red, A = red).
  • Level filter chips — toggle each log level on / off.
  • Search — filter by tag or message text.
  • Error count indicator.

Detail view shows:

  • Full message (copyable).
  • Stacktrace (copyable, if present).
  • Timestamp.
  • Metadata table (if present).

Advanced

Custom logging library

Implement the LogCollector interface — or just pass the singleton LogMonitorStore directly to your writer, since the store implements LogCollector:

fun interface LogCollector {
    fun log(level: LogLevel, tag: String, message: String, throwable: Throwable?)
}
val logPlugin = remember { LogMonitorPlugin() }
MyLoggingSDK.addWriter(LogMonitorStore) // LogMonitorStore implements LogCollector

For maximum control, call LogMonitorStore.record(...) directly:

LogMonitorStore.record(
    level = LogLevel.INFO,
    tag = "MyTag",
    message = "Something happened",
    throwable = null,
    metadata = mapOf("requestId" to "abc-123"), // optional
)

Timber example (Android)

class SidekickTree : Timber.Tree() {
    override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
        val level = when (priority) {
            Log.VERBOSE -> LogLevel.VERBOSE
            Log.DEBUG   -> LogLevel.DEBUG
            Log.INFO    -> LogLevel.INFO
            Log.WARN    -> LogLevel.WARN
            Log.ERROR   -> LogLevel.ERROR
            Log.ASSERT  -> LogLevel.ASSERT
            else        -> LogLevel.DEBUG
        }
        LogMonitorStore.record(level = level, tag = tag ?: "App", message = message, throwable = t)
    }
}

Plant the tree at startup:

val logPlugin = remember { LogMonitorPlugin() }

LaunchedEffect(Unit) {
    Timber.plant(SidekickTree())
}

Note

Timber is Android-only. For multiplatform projects, prefer the Kermit bridge.

See also