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:
Decoupled Lifecycles: A task scheduled by
KmpWorkermay 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.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.
Cross-Platform Diagnostic Discrepancies: Android logs task lifecycle events via
androidx.work.WorkInfowith 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 onemit().
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
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.
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)).Make Diagnostics Accessible in Debug Builds: Exposing a visual inspector like
KmpWorkerInspectorScreeninside 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:
Part 1: Unifying Android WorkManager and iOS BGTaskScheduler: Managing OS architectural differences, constraints, execution time limits, and exponential backoff retry policies.
Part 2: Offline-First Sync, SQLite Queues, and DAG Execution: Architecting durable queues with SQLDelight, detecting real IP egress on iOS, and running branching dependency graphs.
Part 3: Real-Time Telemetry and Visual Debugging: Streaming task states through
TaskMonitor, cold-launch replay withEventStore, and building an on-device Compose Multiplatform inspector.
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
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.