Back to blog
Kotlin MultiplatformKMPCompose MultiplatformTelemetryAndroidiOSBackground TasksDebuggingSQLDelightOpen Source

Real-Time Telemetry and Visual Debugging for Kotlin Multiplatform Background Tasks

N
Neural Heads Team
Engineering
September 6, 2026
Real-Time Telemetry and Visual Debugging for Kotlin Multiplatform Background Tasks

Real-Time Telemetry and Visual Debugging for Kotlin Multiplatform Background Tasks

In Part 1 of this series, we tackled the mechanics of unifying Android’s WorkManager and iOS’s BGTaskScheduler under a single Kotlin coroutines API. In Part 2, we explored how to make those background executions durable across offline periods and process deaths using SQLite queues and directed acyclic graphs.

Once you have scheduled, persistent background jobs running on consumer devices, you hit a different engineering reality: background tasks fail in the dark.

When a foreground UI breaks, users notice immediately and crash-reporting tools capture the stack trace with view hierarchy breadcrumbs. But when a periodic database sync drops silently at 3:00 AM on a customer’s phone because iOS killed the background budget or Android revoked an unmetered network capability, there is no UI to show an error dialog.

Traditionally, diagnosing background execution issues meant connecting a physical device to a developer workstation, triggering simulated background events via ADB or LLDB, and filtering through thousands of noisy log lines in Logcat or the Apple Unified Logging system. That workflow does not scale, does not work during field QA, and completely falls apart across cross-platform teams where Android engineers might not know their way around Xcode’s console (or vice versa).

To give mobile teams visibility into background execution without attaching a debugger, we built an observability and telemetry pipeline directly into KMPWorker. This article walks through the architecture of our reactive event bus, cold-launch event persistence, historical execution metrics, and the Compose Multiplatform visual inspector.


The Observability Problem in Mobile Background Execution

Observing background work across Android and iOS presents three distinct architectural challenges:

  1. Decoupled Lifecycles: A task scheduled by KmpWorker may run hours after the user navigates away from the screen that enqueued it. The UI components that care about the outcome are often not mounted in memory when the job finishes.

  2. Cold-Launch Blindness: If the OS awakens your app process in the background to execute a task, runs it to completion, and then terminates the process, any in-memory event bus or coroutine channel evaporates. When the user manually launches the app hours later, the UI has no way of knowing whether the overnight sync succeeded, failed, or timed out.

  3. Cross-Platform Diagnostic Discrepancies: Android logs task lifecycle events via androidx.work.WorkInfo with explicit failure outputs. iOS terminates background tasks with opaque Mach kernel signals or unceremonious SIGKILLs when exceeding the ~30-second execution window. Unifying telemetry requires a shared schema that captures durations, retry counts, and terminal states uniformly.


The Reactive Backbone: TaskMonitor

At the center of KMPWorker’s telemetry architecture sits TaskMonitor, a shared event hub implemented in kmpworker-core:

object TaskMonitor {
    private val states = MutableSharedFlow<Pair<String, TaskState>>(
        replay = 1,
        extraBufferCapacity = 64
    )

    @Volatile 
    private var eventStore: EventStore? = null

    suspend fun emit(taskId: String, state: TaskState) {
        KmpWorkerLogger.d("TaskMonitor: $taskId → $state")
        if (state.isTerminal) {
            eventStore?.record(taskId, state)
        }
        states.emit(taskId to state)
    }

    fun observe(taskId: String): Flow<TaskState> {
        return states
            .filter { (id, _) -> id == taskId }
            .map { (_, state) -> state }
    }

    fun observeAll(): Flow<Pair<String, TaskState>> = states
}

Buffer Sizing and Backpressure

Notice the configuration of MutableSharedFlow:

  • replay = 1: Guarantees that late subscribers (such as a ViewModel initializing on screen navigation) immediately receive the most recent state emission for an active or completed task.

  • extraBufferCapacity = 64: Mobile apps frequently handle bursts of concurrent tasks. If multiple background operations complete while a UI collector is temporarily suspended or doing heavy main-thread layout work, the buffer prevents worker execution threads from suspending on emit().


Surviving Process Death: EventStore and Cold-Launch Replay

An in-memory SharedFlow solves real-time updates while the app is alive. To solve the cold-launch blindness problem, TaskMonitor integrates with an optional EventStore.

When a task transitions into a terminal state (TaskState.Success, TaskState.Failed, or TaskState.Cancelled), TaskMonitor.emit() persists the event to SQLite before emitting it to the in-memory stream:

suspend fun emit(taskId: String, state: TaskState) {
    if (state.isTerminal) {
        eventStore?.record(taskId, state)
    }
    states.emit(taskId to state)
}

The underlying storage is backed by SQLDelight in the task_events table:

-- KMPWorker task event persistence schema
-- Database: KmpWorkerDatabase

CREATE TABLE IF NOT EXISTS task_events (
    id          INTEGER NOT NULL PRIMARY KEY,
    task_id     TEXT    NOT NULL,
    state_type  TEXT    NOT NULL,              -- 'Success' | 'Failed' | 'Cancelled'
    error_msg   TEXT,                          -- Non-null for Failed states
    retry_count INTEGER NOT NULL DEFAULT 0,
    will_retry  INTEGER NOT NULL DEFAULT 0,   -- 1 = true, 0 = false
    replayed    INTEGER NOT NULL DEFAULT 0,   -- 1 = already replayed on cold launch
    created_at  INTEGER NOT NULL
);

insertEvent:
INSERT INTO task_events (task_id, state_type, error_msg, retry_count, will_retry, replayed, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?);

getUnreplayed:
SELECT * FROM task_events WHERE replayed = 0 ORDER BY created_at ASC;

markReplayed:
UPDATE task_events SET replayed = 1 WHERE id = ?;

The Replay Sequence on App Startup

When the application boots (in Android’s Application.onCreate or iOS’s AppDelegate / SwiftUI init), you install the store and trigger replay before the UI renders:

// In shared application startup
TaskMonitor.install(SqlDelightEventStore(database))
TaskMonitor.replayPendingEvents()

Under the hood, SqlDelightEventStore.replayAll() pulls all rows where replayed = 0, reconstructs the polymorphic TaskState instances, and emits them into TaskMonitor:

override suspend fun replayAll(
    emit: suspend (taskId: String, state: TaskState) -> Unit
): Unit = withContext(Dispatchers.IO) {
    val events = queries.getUnreplayed().executeAsList()
    for (event in events) {
        val state = event.toTaskState() ?: continue
        withContext(Dispatchers.Default) {
            emit(event.task_id, state)
        }
        queries.markReplayed(event.id)
    }
}

Once replayed, markReplayed(event.id) flags the record so it is never delivered twice. This ensures that if a critical data sync ran at midnight while the app was dead, your presentation layer receives the TaskState.Success event the moment the user launches the app the next morning.


Historical Telemetry and Performance Profiling

Tracking current state transitions is essential for UI reactivity, but answering questions like “How often is our sync failing?” or “What is our 95th percentile execution duration on cellular data?” requires structured execution history.

We introduced the TelemetryCollector interface:

interface TelemetryCollector {
    suspend fun onTaskStarted(taskId: String, timestamp: Long)
    suspend fun onTaskCompleted(taskId: String, state: TaskState, timestamp: Long, retryCount: Int)
    suspend fun getHistory(limit: Int = 100, stateFilter: String? = null): List<ExecutionRecord>
    suspend fun clearHistory()
    suspend fun pruneHistory(olderThanMillis: Long)
}

Thread-Safe Timestamp Tracking

Measuring execution duration sounds trivial until tasks run concurrently across worker threads. In SqlDelightTelemetryCollector, we manage task start boundaries with a coroutine Mutex:

class SqlDelightTelemetryCollector(
    private val database: KmpWorkerDatabase
) : TelemetryCollector {

    private val queries get() = database.execution_historyQueries
    private val startTimes = mutableMapOf<String, Long>()
    private val mutex = Mutex()

    override suspend fun onTaskStarted(taskId: String, timestamp: Long) {
        mutex.withLock { startTimes[taskId] = timestamp }
    }

    override suspend fun onTaskCompleted(
        taskId: String,
        state: TaskState,
        timestamp: Long,
        retryCount: Int
    ): Unit = withContext(Dispatchers.Default) {
        val startedAt = mutex.withLock { startTimes.remove(taskId) } ?: timestamp
        val durationMs = timestamp - startedAt
        val stateStr = when (state) {
            is TaskState.Success -> "SUCCESS"
            is TaskState.Failed -> "FAILED"
            is TaskState.Cancelled -> "CANCELLED"
            is TaskState.TimedOut -> "TIMED_OUT"
            else -> "UNKNOWN"
        }
        val errorMsg = when (state) {
            is TaskState.Failed -> state.throwable.message
            is TaskState.TimedOut -> "Timed out after ${state.afterMillis}ms"
            is TaskState.Cancelled -> state.reason.takeIf { it.isNotEmpty() }
            else -> null
        }

        queries.insertRecord(
            task_id     = taskId,
            started_at  = startedAt,
            completed_at = timestamp,
            duration_ms = durationMs,
            state       = stateStr,
            retry_count = retryCount.toLong(),
            error_msg   = errorMsg
        )
    }
}

Each completed run generates an immutable ExecutionRecord:

data class ExecutionRecord(
    val taskId: String,
    val startedAt: Long,
    val completedAt: Long,
    val durationMs: Long,
    val state: String,
    val retryCount: Int,
    val error: String?
)

Because ExecutionRecord records duration in raw milliseconds alongside retry counts and error strings, this data can be queried by in-app debuggers or exported to external analytics platforms (Datadog, Firebase, Sentry) without platform-specific schema translation.


On-Device Debugging: The KMPWorker Inspector

Rather than forcing developers to query SQLite tables or inspect ADB outputs, we leveraged Compose Multiplatform to build an interactive, on-device diagnostic dashboard: KmpWorkerInspectorScreen in the :inspector module.

Because it is written in 100% shared Compose code, the exact same UI runs embedded in an Android debug drawer, inside an iOS SwiftUI wrapper via ComposeUIViewController, or in a standalone desktop tool.

┌────────────────────────────────────────────────────────────────────────┐
│  KMPWorker Inspector  [● LIVE]                    [➕ Enqueue] [🔄] [🗑️] │
├────────────────────────────────────────────────────────────────────────┤
│  ⚡ ACTIVE JOBS        📈 SUCCESS RATE         ⚠️ ERRORS / TIMEOUTS     │
│     2 Running             94% (47 of 50)           3 Investigating     │
├───────────────────────────────────┬────────────────────────────────────┤
│  ⚙️ REGISTERED HANDLERS            │  📡 ACTIVE EXECUTION QUEUE         │
│  • sync-users     [HANDLER] [▶]   │  • media-upload    [HIGH] [RUNNING]│
│  • cleanup-cache  [⏱️ 30s]   [▶]   │  • telemetry-push  [NORM] [SCHED]  │
├───────────────────────────────────┼────────────────────────────────────┤
│  ⛓️ TASK CHAIN DAG VISUALIZER      │  📊 HISTORICAL TELEMETRY LOGS      │
│  (✓ Fetch) ──▶ (⏳ Decrypt) ──▶ (2) │  • sync-users      124ms [SUCCESS] │
│               [Simulate Chain]    │  • photo-backup   4500ms [FAILED]  │
└───────────────────────────────────┴────────────────────────────────────┘

Inspector Architecture and Capabilities

The inspector subscribes to the worker’s live state channels and renders five primary modules:

1. High-Level Metrics Grid

Aggregates historical data on the fly:

  • Active Jobs: Counts currently scheduled or running tasks.

  • Success Rate Percentage: Ratio of successful terminal states over total executions.

  • Error Count: Highlighting failures and timeout aborts.

2. Registered Handlers with Manual Triggers

Inspects the shared TaskRegistry and displays every task handler registered in the application. Developers can tap “Trigger” on any registered handler to immediately enqueue a test run with a single click, verifying worker behavior without navigating through complex user flows.

3. Real-Time Active Queue

Streams live updates from kmpWorker.observeAll(). Tasks display dynamic priority badges (HIGH, NORMAL, LOW) and status indicators (SCHEDULED, RUNNING, PENDING). When a task finishes, it automatically exits the active queue and appears in the history log.

4. DAG and Task Chain Visualizer

A dedicated visualizer component that displays step progress across chained tasks. During execution, current steps pulse with animated scale transitions, completed steps illuminate green with checkmarks, and connecting vector paths transition dynamically:

val pulseScale by rememberInfiniteTransition().animateFloat(
    initialValue = 1f,
    targetValue = 1.15f,
    animationSpec = infiniteRepeatable(
        animation = tween(1000, easing = FastOutSlowInEasing),
        repeatMode = RepeatMode.Reverse
    )
)

Box(
    modifier = Modifier
        .size(32.dp)
        .graphicsLayer {
            if (state is TaskState.Running) {
                scaleX = pulseScale
                scaleY = pulseScale
            }
        }
        .clip(RoundedCornerShape(16.dp))
        .background(circleColor.copy(alpha = 0.2f))
        .border(2.dp, circleColor, RoundedCornerShape(16.dp)),
    contentAlignment = Alignment.Center
) {
    Text(text = if (state is TaskState.Success) "✓" else index.toString())
}

5. Dynamic Task Enqueue Dialog

Allows QA testers and developers to inject tasks with arbitrary configurations at runtime:

  • Configurable Task ID and Priority (HIGH, NORMAL, LOW).

  • Specific Timeouts (in seconds).

  • System Constraints: Requiring unmetered Wi-Fi, non-roaming network, or active battery charging.

Testing edge cases like “How does our sync handle network roaming while running on battery?” becomes as simple as toggling checkboxes in the dialog on a physical device.


Deterministic Testing with :testing Module

Visual inspection is invaluable during development and QA, but continuous integration requires deterministic, headless unit testing. Background tasks are notoriously difficult to test cleanly because they inherently involve asynchronous delays, platform schedulers, and network state changes.

To support test-driven development, KMPWorker ships a standalone :testing artifact featuring pre-built test fakes:

commonTest.dependencies {
    implementation("io.neuralheads:kmpworker-testing:0.1.0-beta06")
}

Testing ViewModels with FakeKmpWorker

FakeKmpWorker replaces real background schedulers with an in-memory, synchronous implementation that exposes fine-grained simulation hooks:

class SyncViewModelTest {

    private val fakeWorker = FakeKmpWorker()
    private val viewModel = SyncViewModel(worker = fakeWorker)

    @Test
    fun `shows loading spinner while sync task is running`() = runTest {
        // Register mock handler
        fakeWorker.register("sync-todos") { }

        val states = mutableListOf<TaskState>()
        val collectJob = launch(UnconfinedTestDispatcher()) {
            fakeWorker.observe("sync-todos").toList(states)
        }

        // Trigger sync via ViewModel
        viewModel.onRefreshClicked()

        // Verify task was scheduled
        assertTrue(fakeWorker.wasEnqueued("sync-todos"))

        // Simulate successful completion
        fakeWorker.simulateSuccess("sync-todos")
        collectJob.cancel()

        // Assert state transitions: Scheduled -> Running -> Success
        assertTrue(states.any { it is TaskState.Running })
        assertEquals(TaskState.Success, states.last())
    }

    @AfterTest
    fun tearDown() {
        fakeWorker.reset()
    }
}

Simulating Flaky Connectivity with FakeNetworkMonitor

In Part 2, we explored how OfflineQueue buffers tasks when offline. With FakeNetworkMonitor, testing that behavior takes less than ten lines of code:

@Test
fun `offline queue holds tasks until network is restored`() = runTest {
    val fakeWorker = FakeKmpWorker()
    val fakeRepo = FakeTaskRepository()
    val fakeNetwork = FakeNetworkMonitor(initiallyOnline = false)

    val queue = OfflineQueue(fakeWorker, fakeRepo, fakeNetwork)
    queue.start()

    val task = TaskRequest(id = "deferred-sync", type = TaskType.OneTime)
    queue.enqueue(task)

    // Task should be in SQLite, not dispatched to worker
    assertFalse(fakeWorker.wasEnqueued("deferred-sync"))
    assertEquals(1, fakeRepo.getAll().size)

    // Simulate network reconnecting
    fakeNetwork.setOnline(true)

    // Queue drains automatically
    assertTrue(fakeWorker.wasEnqueued("deferred-sync"))
    assertEquals(0, fakeRepo.getAll().size)
}

No mocks, no Mockito/MockK byte-buddy reflection issues on Kotlin/Native, and zero flaky sleeps.


Practical Takeaways for Mobile Engineers

  1. Persist Terminal Events Before Emitting: If you rely strictly on coroutine flows to publish task completion states, you will lose events during process kills. Persisting terminal states to SQLite prior to in-memory broadcast gives you an ironclad audit trail.

  2. Bound Your Storage Growth: An unconstrained execution history table will eventually consume megabytes of device storage. Always configure automated pruning on startup (e.g. telemetry.pruneHistory(olderThanMillis = 7.days.inWholeMilliseconds)).

  3. Make Diagnostics Accessible in Debug Builds: Exposing a visual inspector like KmpWorkerInspectorScreen inside your debug settings or internal QA builds empowers non-technical testers to report concrete failure reasons rather than vague “sync didn’t work” tickets.


Summary of the KMPWorker Series

Over this 3-part series, we have covered the entire lifecycle of enterprise-grade background execution in Kotlin Multiplatform:

The entire KMPWorker framework—including the core engine, offline queue, persistence layer, inspector, and testing fakes—is open source on GitHub: neuralheads/kmpworker.

About the Author

N

The engineering team at Neural Heads, building the future of software one project at a time.

Share this post

Engineering Insights

Get the latest engineering insights and product updates from the studio.

No spam. Pure engineering. Unsubscribe anytime.