
HTTP-capture debug toolkit: Ktor interceptor, bounded event bus with ring-buffer, header redaction, in-app overlay (shake to open), and zero-install live web viewer streaming events.
A Kotlin Multiplatform debug toolkit — HTTP capture for Ktor, an in-app Compose overlay, and a live web viewer you open in any browser.
Capture runs on JVM, Android, and iOS. View traffic in an in-app overlay, or stream it live to any browser on your network — no install, no cable. (Browser viewer: JVM desktop via ./gradlew :ui:run, or in-app on iOS via DebugKitWebViewer.start().)
Status: v0.2.0 on Maven Central (
io.github.meeladheeraj) · JVM · Android · iOS · (early — APIs may change)
KMP debug tooling is a known gap. The existing options are fragmented, and none offer a polished live web viewer that works on iOS. DebugKit's whole architecture is built around one idea:
Everything is a producer or consumer of a single event stream.
Capture is fully decoupled from display, so the same captured events render in an in-app overlay and stream to a browser — and neither knows the other exists.
[Ktor plugin] [Logger] [Crash hook] <- producers
\ | /
+----------------------+
| DebugBus | SharedFlow<DebugEvent> <- the spine
| + EventStore | bounded ring buffer (StateFlow)
+----------------------+
/ \
[Compose overlay] [Web viewer] <- consumers
(native window) (browser, live)
DebugKit.install() // wire capture -> store (at app startup)
val client = HttpClient(engine) {
install(DebugKitPlugin) // capture every request, automatically
}
DebugKit.redactHeaders("Authorization", "Cookie") // secrets never hit the log
startDebugServer(DebugKit.store) // optional: live web viewer at :8080Your calling code never references the toolkit again — capture happens invisibly in the Ktor pipeline.
Published with Gradle Module Metadata, so one coordinate resolves the right variant (JVM jar / Android AAR / iOS klib) for each consumer:
dependencies {
debugImplementation("io.github.meeladheeraj:core:0.2.0")
debugImplementation("io.github.meeladheeraj:interceptor-ktor:0.2.0")
// The toolkit COMPILES OUT of release builds: identical API, empty bodies.
releaseImplementation("io.github.meeladheeraj:core-noop:0.2.0")
releaseImplementation("io.github.meeladheeraj:interceptor-ktor-noop:0.2.0")
}Your app code is identical in both build types. Debug captures everything; release captures nothing and ships none of the engine.
iOS / Swift consumers use the prebuilt framework instead:
import DebugKit
DebugKitDebugKit.shared.install()
let events = DebugKitDebugKit.shared.store.snapshot()A complete, realistic setup — wire it once at startup, then use your Ktor client normally.
import com.klarity.debugkit.core.DebugKit
import com.klarity.debugkit.core.DebugEvent
import com.klarity.debugkit.ktor.DebugKitPlugin
import com.klarity.debugkit.server.startDebugServer
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
// 1. At app startup — Application.onCreate() on Android, @main on iOS, or your KMP init.
fun initDebugging() {
DebugKit.install() // start piping events into the store
DebugKit.redactHeaders("Authorization", "Cookie") // strip secrets before they're stored
DebugKit.maxBodyChars = 250_000 // (optional) bound very large bodies
startDebugServer(DebugKit.store) // (optional) live web viewer at :8080
}
// 2. Build your Ktor client ONCE with the plugin installed.
// Pick your platform engine: CIO/OkHttp on Android & JVM, Darwin on iOS.
val httpClient = HttpClient(/* engine */) {
install(DebugKitPlugin) // captures every request, invisibly
}
// 3. Use the client like normal — nothing here references the toolkit; capture just happens.
suspend fun loadUsers(): String =
httpClient.get("https://api.example.com/users").bodyAsText()Reading the captured events back:
// One-off snapshot (e.g. to dump on a crash):
val recent: List<DebugEvent> = DebugKit.store.snapshot()
// Or observe reactively — the list updates live as requests happen:
import androidx.compose.runtime.*
import com.klarity.debugkit.ui.DebugOverlay
import com.klarity.debugkit.ui.DebugOverlayHost
@Composable
fun DebugScreen() {
DebugOverlay() // the built-in overlay UI
// …or build your own from the same source of truth:
// val events by DebugKit.store.events.collectAsState()
}Shake to open (Android). Wrap your app root in DebugOverlayHost and start the shake
detector — shake the device to toggle the overlay on top of everything (or call
openDebugger() from a debug-menu button). The overlay only mounts while visible.
import com.klarity.debugkit.ui.DebugOverlayHost
import com.klarity.debugkit.ui.DebugKitShake
import com.klarity.debugkit.ui.openDebugger
class App : Application() {
override fun onCreate() {
super.onCreate()
DebugKit.install()
DebugKitShake.start(this) // shake → toggleDebugger()
}
}
// In your Activity:
setContent {
DebugOverlayHost { // draws the overlay above your UI
MyApp()
}
}The tapped HTTP detail opens as Request / Response / cURL tabs, with JSON bodies
pretty-printed and request bodies captured (and replayed in the cURL --data).
In a release build, the no-op artifacts replace core / interceptor-ktor: install() is empty and DebugKitPlugin captures nothing — with zero changes to this code. The web-viewer server is debug-only by nature; keep it as a debugImplementation and guard startDebugServer(...) behind your debug flag so it never ships to production.
On iOS, capture works two ways depending on how your app makes HTTP calls. Swift reads the captured events through the framework either way.
Scope note: the Ktor (shared Kotlin) path below is the canonical, write-once KMP route. The
URLSessionpath uses the optionalDebugKitURLSessionadapter — a non-KMP add-on beyond core v1 (nativeURLSessioninterception is out of scope for v1). Seeswift/DebugKitURLSession/README.md.
1. Build & embed the framework:
./gradlew :ios-framework:assembleDebugKitReleaseXCFramework
# → ios-framework/build/XCFrameworks/release/DebugKit.xcframeworkDrag it into your Xcode target's Frameworks, Libraries, and Embedded Content (or wire it up via SPM / CocoaPods). For URLSession capture, also add the DebugKitURLSession Swift package (swift/DebugKitURLSession).
2. Capture traffic — pick what matches your app:
(a) URLSession apps — use the DebugKitURLSession package. One line at launch, no networking changes:
import DebugKitURLSession
DebugKitCapture.redactedHeaders = ["authorization", "cookie"]
DebugKitCapture.install() // auto-captures URLSession.shared
// For a custom-configured session:
let config = URLSessionConfiguration.default
DebugKitCapture.enable(on: config)
let session = URLSession(configuration: config)
// Or record manually from your own networking layer:
// DebugKitCapture.record(request:response:data:durationMs:)(b) Ktor (shared Kotlin) — install the plugin on a Darwin-engine client in iosMain:
import io.ktor.client.engine.darwin.Darwin
// DebugKit.install() once at startup, then:
val httpClient = HttpClient(Darwin) { install(DebugKitPlugin) }3. View the captured events — three ways:
In-app SwiftUI screen (live list, tap-to-expand, Clear):
import DebugKitURLSession
.sheet(isPresented: $showDebug) { DebugKitView() } // e.g. behind a shake gestureIn a browser on your laptop (same Wi-Fi):
DebugKitWebViewer.start() // at launch (debug); prints http://<phone-ip>:8080…or read them yourself:
import DebugKit
let events = DebugKitDebugKit.shared.store.snapshot()
for case let http as DebugKitHttpEvent in events {
print("\(http.method) \(http.url) → \(http.statusCode?.intValue ?? -1) (\(http.durationMs) ms)")
}Notes for Swift consumers:
objects appear as .shared; classes carry the framework prefix (DebugKitDebugKit, DebugKitHttpEvent).statusCode is a boxed KotlinInt? (use .intValue); durationMs is an Int64.snapshot()), collect the Kotlin Flow store.events — add SKIE or KMP-NativeCoroutines for idiomatic Swift async / Combine bridging.Note:
URLSessioncapture lives in the SwiftDebugKitURLSessionlayer (aURLProtocolinterceptor); the KotlinDebugKitPlugincovers Ktor. Both record into the sameDebugKit.store, so the overlay and viewer treat them identically.
startDebugServer(DebugKit.store) runs a tiny embedded server inside your app. Open http://localhost:8080 (or the device's LAN IP) on any laptop on the same network:
Where it runs:
server module, started for you by ./gradlew :ui:run.DebugKitWebViewer.start() at launch and open the printed http://<phone-ip>:8080 on your laptop. (Polls /events.json once a second; the browser stays a stateless terminal.)server module also builds for Android; call startDebugServer(DebugKit.store) from a debug build (add the INTERNET permission) and open the device's LAN IP. Pair it with the in-app overlay + shake-to-open above.| Module | What it is | Targets |
|---|---|---|
core |
DebugEvent model, DebugBus (SharedFlow), EventStore (bounded ring buffer), DebugKit facade, redaction |
JVM · Android · iOS |
interceptor-ktor |
Ktor client plugin → emits HttpEvents |
JVM · Android · iOS |
core-noop / interceptor-ktor-noop
|
API-symmetric empty builds for release | JVM · Android · iOS |
ui |
Compose Multiplatform overlay (DebugOverlay) |
Desktop (run it from the terminal) |
server |
Embedded Ktor server + WebSocket web viewer | JVM |
ios-framework |
Umbrella that packages DebugKit.xcframework
|
iOS |
DebugKit.install(...).debugImplementation / releaseImplementation; the engine is absent from release binaries.DebugKit.redactHeaders(...); secrets are stripped before they reach the store.# Prereq for terminal builds (no system Java): use the JDK bundled with Android Studio.
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
./gradlew :ui:run # native overlay + web viewer at :8080
./gradlew :core:jvmTest # tests on JVM
./gradlew :core:iosSimulatorArm64Test # the same tests on the iOS simulator
./gradlew assemble # JVM jars + Android AARs
./gradlew :ios-framework:assembleDebugKitReleaseXCFramework # DebugKit.xcframework
./gradlew publishToMavenLocal # publish to ~/.m2
./gradlew :noop-api-check:jvmTest # fail if core / core-noop APIs driftAlways use
./gradlew(the wrapper, pinned to Gradle 8.11.1), never a systemgradle.
Kotlin Multiplatform · Ktor (client + server) · Compose Multiplatform · kotlinx.serialization · kotlinx.coroutines
A Kotlin Multiplatform debug toolkit — HTTP capture for Ktor, an in-app Compose overlay, and a live web viewer you open in any browser.
Capture runs on JVM, Android, and iOS. View traffic in an in-app overlay, or stream it live to any browser on your network — no install, no cable. (Browser viewer: JVM desktop via ./gradlew :ui:run, or in-app on iOS via DebugKitWebViewer.start().)
Status: v0.2.0 on Maven Central (
io.github.meeladheeraj) · JVM · Android · iOS · (early — APIs may change)
KMP debug tooling is a known gap. The existing options are fragmented, and none offer a polished live web viewer that works on iOS. DebugKit's whole architecture is built around one idea:
Everything is a producer or consumer of a single event stream.
Capture is fully decoupled from display, so the same captured events render in an in-app overlay and stream to a browser — and neither knows the other exists.
[Ktor plugin] [Logger] [Crash hook] <- producers
\ | /
+----------------------+
| DebugBus | SharedFlow<DebugEvent> <- the spine
| + EventStore | bounded ring buffer (StateFlow)
+----------------------+
/ \
[Compose overlay] [Web viewer] <- consumers
(native window) (browser, live)
DebugKit.install() // wire capture -> store (at app startup)
val client = HttpClient(engine) {
install(DebugKitPlugin) // capture every request, automatically
}
DebugKit.redactHeaders("Authorization", "Cookie") // secrets never hit the log
startDebugServer(DebugKit.store) // optional: live web viewer at :8080Your calling code never references the toolkit again — capture happens invisibly in the Ktor pipeline.
Published with Gradle Module Metadata, so one coordinate resolves the right variant (JVM jar / Android AAR / iOS klib) for each consumer:
dependencies {
debugImplementation("io.github.meeladheeraj:core:0.2.0")
debugImplementation("io.github.meeladheeraj:interceptor-ktor:0.2.0")
// The toolkit COMPILES OUT of release builds: identical API, empty bodies.
releaseImplementation("io.github.meeladheeraj:core-noop:0.2.0")
releaseImplementation("io.github.meeladheeraj:interceptor-ktor-noop:0.2.0")
}Your app code is identical in both build types. Debug captures everything; release captures nothing and ships none of the engine.
iOS / Swift consumers use the prebuilt framework instead:
import DebugKit
DebugKitDebugKit.shared.install()
let events = DebugKitDebugKit.shared.store.snapshot()A complete, realistic setup — wire it once at startup, then use your Ktor client normally.
import com.klarity.debugkit.core.DebugKit
import com.klarity.debugkit.core.DebugEvent
import com.klarity.debugkit.ktor.DebugKitPlugin
import com.klarity.debugkit.server.startDebugServer
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
// 1. At app startup — Application.onCreate() on Android, @main on iOS, or your KMP init.
fun initDebugging() {
DebugKit.install() // start piping events into the store
DebugKit.redactHeaders("Authorization", "Cookie") // strip secrets before they're stored
DebugKit.maxBodyChars = 250_000 // (optional) bound very large bodies
startDebugServer(DebugKit.store) // (optional) live web viewer at :8080
}
// 2. Build your Ktor client ONCE with the plugin installed.
// Pick your platform engine: CIO/OkHttp on Android & JVM, Darwin on iOS.
val httpClient = HttpClient(/* engine */) {
install(DebugKitPlugin) // captures every request, invisibly
}
// 3. Use the client like normal — nothing here references the toolkit; capture just happens.
suspend fun loadUsers(): String =
httpClient.get("https://api.example.com/users").bodyAsText()Reading the captured events back:
// One-off snapshot (e.g. to dump on a crash):
val recent: List<DebugEvent> = DebugKit.store.snapshot()
// Or observe reactively — the list updates live as requests happen:
import androidx.compose.runtime.*
import com.klarity.debugkit.ui.DebugOverlay
import com.klarity.debugkit.ui.DebugOverlayHost
@Composable
fun DebugScreen() {
DebugOverlay() // the built-in overlay UI
// …or build your own from the same source of truth:
// val events by DebugKit.store.events.collectAsState()
}Shake to open (Android). Wrap your app root in DebugOverlayHost and start the shake
detector — shake the device to toggle the overlay on top of everything (or call
openDebugger() from a debug-menu button). The overlay only mounts while visible.
import com.klarity.debugkit.ui.DebugOverlayHost
import com.klarity.debugkit.ui.DebugKitShake
import com.klarity.debugkit.ui.openDebugger
class App : Application() {
override fun onCreate() {
super.onCreate()
DebugKit.install()
DebugKitShake.start(this) // shake → toggleDebugger()
}
}
// In your Activity:
setContent {
DebugOverlayHost { // draws the overlay above your UI
MyApp()
}
}The tapped HTTP detail opens as Request / Response / cURL tabs, with JSON bodies
pretty-printed and request bodies captured (and replayed in the cURL --data).
In a release build, the no-op artifacts replace core / interceptor-ktor: install() is empty and DebugKitPlugin captures nothing — with zero changes to this code. The web-viewer server is debug-only by nature; keep it as a debugImplementation and guard startDebugServer(...) behind your debug flag so it never ships to production.
On iOS, capture works two ways depending on how your app makes HTTP calls. Swift reads the captured events through the framework either way.
Scope note: the Ktor (shared Kotlin) path below is the canonical, write-once KMP route. The
URLSessionpath uses the optionalDebugKitURLSessionadapter — a non-KMP add-on beyond core v1 (nativeURLSessioninterception is out of scope for v1). Seeswift/DebugKitURLSession/README.md.
1. Build & embed the framework:
./gradlew :ios-framework:assembleDebugKitReleaseXCFramework
# → ios-framework/build/XCFrameworks/release/DebugKit.xcframeworkDrag it into your Xcode target's Frameworks, Libraries, and Embedded Content (or wire it up via SPM / CocoaPods). For URLSession capture, also add the DebugKitURLSession Swift package (swift/DebugKitURLSession).
2. Capture traffic — pick what matches your app:
(a) URLSession apps — use the DebugKitURLSession package. One line at launch, no networking changes:
import DebugKitURLSession
DebugKitCapture.redactedHeaders = ["authorization", "cookie"]
DebugKitCapture.install() // auto-captures URLSession.shared
// For a custom-configured session:
let config = URLSessionConfiguration.default
DebugKitCapture.enable(on: config)
let session = URLSession(configuration: config)
// Or record manually from your own networking layer:
// DebugKitCapture.record(request:response:data:durationMs:)(b) Ktor (shared Kotlin) — install the plugin on a Darwin-engine client in iosMain:
import io.ktor.client.engine.darwin.Darwin
// DebugKit.install() once at startup, then:
val httpClient = HttpClient(Darwin) { install(DebugKitPlugin) }3. View the captured events — three ways:
In-app SwiftUI screen (live list, tap-to-expand, Clear):
import DebugKitURLSession
.sheet(isPresented: $showDebug) { DebugKitView() } // e.g. behind a shake gestureIn a browser on your laptop (same Wi-Fi):
DebugKitWebViewer.start() // at launch (debug); prints http://<phone-ip>:8080…or read them yourself:
import DebugKit
let events = DebugKitDebugKit.shared.store.snapshot()
for case let http as DebugKitHttpEvent in events {
print("\(http.method) \(http.url) → \(http.statusCode?.intValue ?? -1) (\(http.durationMs) ms)")
}Notes for Swift consumers:
objects appear as .shared; classes carry the framework prefix (DebugKitDebugKit, DebugKitHttpEvent).statusCode is a boxed KotlinInt? (use .intValue); durationMs is an Int64.snapshot()), collect the Kotlin Flow store.events — add SKIE or KMP-NativeCoroutines for idiomatic Swift async / Combine bridging.Note:
URLSessioncapture lives in the SwiftDebugKitURLSessionlayer (aURLProtocolinterceptor); the KotlinDebugKitPlugincovers Ktor. Both record into the sameDebugKit.store, so the overlay and viewer treat them identically.
startDebugServer(DebugKit.store) runs a tiny embedded server inside your app. Open http://localhost:8080 (or the device's LAN IP) on any laptop on the same network:
Where it runs:
server module, started for you by ./gradlew :ui:run.DebugKitWebViewer.start() at launch and open the printed http://<phone-ip>:8080 on your laptop. (Polls /events.json once a second; the browser stays a stateless terminal.)server module also builds for Android; call startDebugServer(DebugKit.store) from a debug build (add the INTERNET permission) and open the device's LAN IP. Pair it with the in-app overlay + shake-to-open above.| Module | What it is | Targets |
|---|---|---|
core |
DebugEvent model, DebugBus (SharedFlow), EventStore (bounded ring buffer), DebugKit facade, redaction |
JVM · Android · iOS |
interceptor-ktor |
Ktor client plugin → emits HttpEvents |
JVM · Android · iOS |
core-noop / interceptor-ktor-noop
|
API-symmetric empty builds for release | JVM · Android · iOS |
ui |
Compose Multiplatform overlay (DebugOverlay) |
Desktop (run it from the terminal) |
server |
Embedded Ktor server + WebSocket web viewer | JVM |
ios-framework |
Umbrella that packages DebugKit.xcframework
|
iOS |
DebugKit.install(...).debugImplementation / releaseImplementation; the engine is absent from release binaries.DebugKit.redactHeaders(...); secrets are stripped before they reach the store.# Prereq for terminal builds (no system Java): use the JDK bundled with Android Studio.
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
./gradlew :ui:run # native overlay + web viewer at :8080
./gradlew :core:jvmTest # tests on JVM
./gradlew :core:iosSimulatorArm64Test # the same tests on the iOS simulator
./gradlew assemble # JVM jars + Android AARs
./gradlew :ios-framework:assembleDebugKitReleaseXCFramework # DebugKit.xcframework
./gradlew publishToMavenLocal # publish to ~/.m2
./gradlew :noop-api-check:jvmTest # fail if core / core-noop APIs driftAlways use
./gradlew(the wrapper, pinned to Gradle 8.11.1), never a systemgradle.
Kotlin Multiplatform · Ktor (client + server) · Compose Multiplatform · kotlinx.serialization · kotlinx.coroutines