
Lightweight observability for coroutines offering real‑time lifecycle tracing, P50/P90/P99 metrics, failure rates, flexible sampling strategies, pluggable exporters, and live trace visualization.
Coroutine Telemetry a Kotlin Multiplatform library for observing structured concurrency in Kotlin Coroutines. It enables tracing and visualization of coroutine execution by exposing coroutine hierarchies, lifecycles, suspension points, execution timing, and failure propagation across platforms, enabling deep analysis of coroutine behavior.
https://github.com/user-attachments/assets/900caecc-4b20-4dfb-ba57-ac44d622b9e3
See comet-demo for a full KMP sample app (Android + iOS) demonstrating Comet and comet-visualizer integration with real API calls and some coroutine patterns.
// build.gradle.kts
dependencies {
implementation("io.github.pandubaraja:comet:0.3.0")
}import io.pandu.Comet
import io.pandu.core.telemetry.exporters.CallbackCoroutineTelemetryExporter
import kotlinx.coroutines.*
// 1. Create Comet instance (once, at app startup)
val comet = Comet.create {
exporter(CallbackCoroutineTelemetryExporter(
onEvent = { event -> println("[Comet] $event") },
onMetrics = { metrics -> println("[Metrics] Active: ${metrics.activeCoroutines}") }
))
bufferSize(8192) // Must be a power of 2
}
comet.start()
// 2. Add comet.traced("name") to ANY launch - that's it!
scope.launch(comet.traced("my-operation")) {
// This and ALL child coroutines are automatically traced!
launch(CoroutineName("child-task")) { /* auto traced with name */ }
async(CoroutineName("async-task")) { /* auto traced with name */ }
}// BEFORE: No tracing
viewModelScope.launch {
val user = userRepo.get(id)
}
// AFTER: Just add comet.traced() - done!
viewModelScope.launch(comet.traced("load-user")) {
val user = userRepo.get(id) // Now traced!
}class UserViewModel : ViewModel() {
private val comet = Comet.create {
exporter(CallbackCoroutineTelemetryExporter(
onEvent = { event -> Log.d("Comet", event.toString()) }
))
bufferSize(8192)
}.also { it.start() }
fun loadUser(id: String) {
// Just add comet.traced() - works with viewModelScope!
viewModelScope.launch(comet.traced("load-user")) {
// Use CoroutineName for meaningful span names
val user = async(CoroutineName("fetch-user")) { userRepo.get(id) }
val prefs = async(CoroutineName("fetch-prefs")) { prefsRepo.get(id) }
_state.value = UserState(user.await(), prefs.await())
}
}
// Access metrics anytime
fun getStats() = comet.metrics
override fun onCleared() {
viewModelScope.launch { comet.shutdown() }
}
}class UserPresenter(private val scope: CoroutineScope) {
private val comet = Comet.create {
exporter(CallbackCoroutineTelemetryExporter { event -> println(event) })
bufferSize(8192)
}.also { it.start() }
fun loadUser(id: String) {
scope.launch(comet.traced("load-user")) {
// Use CoroutineName for meaningful child span names
val user = async(CoroutineName("fetch-user")) { api.getUser(id) }
val settings = async(CoroutineName("fetch-settings")) { api.getSettings(id) }
updateUI(user.await(), settings.await())
}
}
}When switching dispatchers with withContext, use .traced() to preserve tracing:
scope.launch(comet.traced("operation")) {
// Use .traced() to keep tracing when switching dispatchers
withContext(Dispatchers.IO.traced()) {
val data = async(CoroutineName("fetch")) { api.getData() }
data.await()
}
}Why?
withContextreplaces the coroutine interceptor..traced()re-wraps the new dispatcher with Comet's telemetry. Without it, child coroutines insidewithContextwon't be traced.
import io.pandu.sampling.strategy.*
// Always sample (development only)
samplingStrategy(AlwaysSamplingStrategy)
// Never sample (disable telemetry)
samplingStrategy(NeverSamplingStrategy)
// Probabilistic (sample X% of root traces)
samplingStrategy(ProbabilisticSamplingStrategy(0.1f))
// Rate limited (max N samples per second)
samplingStrategy(RateLimitedSamplingStrategy(maxPerSecond = 100))
// Operation-based (different rates per operation)
samplingStrategy(OperationBasedSamplingStrategy(
rules = listOf(
OperationBasedSamplingStrategy.OperationRule(Regex("payment-.*"), 1.0f), // Always observe payments
OperationBasedSamplingStrategy.OperationRule(Regex("health-.*"), 0.01f), // Rarely observe health checks
),
defaultRate = 0.1f
))
// Composite (combine multiple strategies)
samplingStrategy(CompositeSamplingStrategy(
strategies = listOf(
OperationBasedSamplingStrategy(...),
RateLimitedSamplingStrategy(maxPerSecond = 1000)
),
mode = CompositeSamplingStrategy.Mode.ANY
))Enable includeStackTrace(true) to capture where coroutines are created:
val comet = Comet.create {
includeStackTrace(true) // Enables source file and line number capture
}When enabled, CoroutineStarted events include creationStackTrace with the call site information. This is useful for debugging and visualization tools like comet-visualizer.
Use withSpan to trace blocks within a suspend function:
import io.pandu.core.tools.withSpan
suspend fun processOrder(orderId: String) = withSpan("process-order") {
val order = withSpan("fetch-order") {
orderRepo.get(orderId)
}
withSpan("validate") {
validator.validate(order)
}
withSpan("save") {
orderRepo.save(order)
}
}Use comet-visualizer for real-time trace visualization in your browser
The visualizer provides:
| Platform | Status | Notes |
|---|---|---|
| JVM | Full | Full stack traces, thread info |
| Android | Full | Full stack traces, thread info |
| iOS | Partial | Limited stack traces |
Copyright 2025
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Coroutine Telemetry a Kotlin Multiplatform library for observing structured concurrency in Kotlin Coroutines. It enables tracing and visualization of coroutine execution by exposing coroutine hierarchies, lifecycles, suspension points, execution timing, and failure propagation across platforms, enabling deep analysis of coroutine behavior.
https://github.com/user-attachments/assets/900caecc-4b20-4dfb-ba57-ac44d622b9e3
See comet-demo for a full KMP sample app (Android + iOS) demonstrating Comet and comet-visualizer integration with real API calls and some coroutine patterns.
// build.gradle.kts
dependencies {
implementation("io.github.pandubaraja:comet:0.3.0")
}import io.pandu.Comet
import io.pandu.core.telemetry.exporters.CallbackCoroutineTelemetryExporter
import kotlinx.coroutines.*
// 1. Create Comet instance (once, at app startup)
val comet = Comet.create {
exporter(CallbackCoroutineTelemetryExporter(
onEvent = { event -> println("[Comet] $event") },
onMetrics = { metrics -> println("[Metrics] Active: ${metrics.activeCoroutines}") }
))
bufferSize(8192) // Must be a power of 2
}
comet.start()
// 2. Add comet.traced("name") to ANY launch - that's it!
scope.launch(comet.traced("my-operation")) {
// This and ALL child coroutines are automatically traced!
launch(CoroutineName("child-task")) { /* auto traced with name */ }
async(CoroutineName("async-task")) { /* auto traced with name */ }
}// BEFORE: No tracing
viewModelScope.launch {
val user = userRepo.get(id)
}
// AFTER: Just add comet.traced() - done!
viewModelScope.launch(comet.traced("load-user")) {
val user = userRepo.get(id) // Now traced!
}class UserViewModel : ViewModel() {
private val comet = Comet.create {
exporter(CallbackCoroutineTelemetryExporter(
onEvent = { event -> Log.d("Comet", event.toString()) }
))
bufferSize(8192)
}.also { it.start() }
fun loadUser(id: String) {
// Just add comet.traced() - works with viewModelScope!
viewModelScope.launch(comet.traced("load-user")) {
// Use CoroutineName for meaningful span names
val user = async(CoroutineName("fetch-user")) { userRepo.get(id) }
val prefs = async(CoroutineName("fetch-prefs")) { prefsRepo.get(id) }
_state.value = UserState(user.await(), prefs.await())
}
}
// Access metrics anytime
fun getStats() = comet.metrics
override fun onCleared() {
viewModelScope.launch { comet.shutdown() }
}
}class UserPresenter(private val scope: CoroutineScope) {
private val comet = Comet.create {
exporter(CallbackCoroutineTelemetryExporter { event -> println(event) })
bufferSize(8192)
}.also { it.start() }
fun loadUser(id: String) {
scope.launch(comet.traced("load-user")) {
// Use CoroutineName for meaningful child span names
val user = async(CoroutineName("fetch-user")) { api.getUser(id) }
val settings = async(CoroutineName("fetch-settings")) { api.getSettings(id) }
updateUI(user.await(), settings.await())
}
}
}When switching dispatchers with withContext, use .traced() to preserve tracing:
scope.launch(comet.traced("operation")) {
// Use .traced() to keep tracing when switching dispatchers
withContext(Dispatchers.IO.traced()) {
val data = async(CoroutineName("fetch")) { api.getData() }
data.await()
}
}Why?
withContextreplaces the coroutine interceptor..traced()re-wraps the new dispatcher with Comet's telemetry. Without it, child coroutines insidewithContextwon't be traced.
import io.pandu.sampling.strategy.*
// Always sample (development only)
samplingStrategy(AlwaysSamplingStrategy)
// Never sample (disable telemetry)
samplingStrategy(NeverSamplingStrategy)
// Probabilistic (sample X% of root traces)
samplingStrategy(ProbabilisticSamplingStrategy(0.1f))
// Rate limited (max N samples per second)
samplingStrategy(RateLimitedSamplingStrategy(maxPerSecond = 100))
// Operation-based (different rates per operation)
samplingStrategy(OperationBasedSamplingStrategy(
rules = listOf(
OperationBasedSamplingStrategy.OperationRule(Regex("payment-.*"), 1.0f), // Always observe payments
OperationBasedSamplingStrategy.OperationRule(Regex("health-.*"), 0.01f), // Rarely observe health checks
),
defaultRate = 0.1f
))
// Composite (combine multiple strategies)
samplingStrategy(CompositeSamplingStrategy(
strategies = listOf(
OperationBasedSamplingStrategy(...),
RateLimitedSamplingStrategy(maxPerSecond = 1000)
),
mode = CompositeSamplingStrategy.Mode.ANY
))Enable includeStackTrace(true) to capture where coroutines are created:
val comet = Comet.create {
includeStackTrace(true) // Enables source file and line number capture
}When enabled, CoroutineStarted events include creationStackTrace with the call site information. This is useful for debugging and visualization tools like comet-visualizer.
Use withSpan to trace blocks within a suspend function:
import io.pandu.core.tools.withSpan
suspend fun processOrder(orderId: String) = withSpan("process-order") {
val order = withSpan("fetch-order") {
orderRepo.get(orderId)
}
withSpan("validate") {
validator.validate(order)
}
withSpan("save") {
orderRepo.save(order)
}
}Use comet-visualizer for real-time trace visualization in your browser
The visualizer provides:
| Platform | Status | Notes |
|---|---|---|
| JVM | Full | Full stack traces, thread info |
| Android | Full | Full stack traces, thread info |
| iOS | Partial | Limited stack traces |
Copyright 2025
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0