
Inspect every HTTP call inside the app via shake or notification — headers, bodies, timings, errors; persistent searchable history, filters, redaction, streams captured, and exports to HAR/cURL.
See every HTTP call your Ktor client makes, inside the app itself. Shake the device or tap the notification: the list of calls opens, with their headers, bodies, timings and errors. For Kotlin Multiplatform and Android apps, with a native UI on each platform: Compose on Android, SwiftUI on iOS.
| Android | ![]() |
![]() |
![]() |
|---|---|---|---|
| iOS | ![]() |
![]() |
1. Add the dependencies, for debug builds only:
// app/build.gradle.kts
dependencies {
debugImplementation("uz.jahonov:ktor-monitor:0.1.0")
debugImplementation("uz.jahonov:ktor-monitor-ui:0.1.0")
}2. Create the monitor and attach it to your client. Release builds do not compile src/debug,
so put it there, and give src/release the same function doing nothing:
// src/debug/kotlin/com/example/DebugTools.kt
fun installDebugTools(app: Application, client: HttpClient) {
val monitor = KtorMonitor(app)
monitor.attach(client)
KtorMonitorUi.install(app, monitor)
}
// src/release/kotlin/com/example/DebugTools.kt
fun installDebugTools(app: Application, client: HttpClient) = Unit3. Call it from Application.onCreate:
class App : Application() {
override fun onCreate() {
super.onCreate()
installDebugTools(this, httpClient)
}
}Run the app, make a call, then shake the device or tap the notification.
1. Depend on the core in the shared module, and export it from the iOS framework, so Swift sees
KtorMonitorBridge:
// shared/build.gradle.kts
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = "Shared"
export("uz.jahonov:ktor-monitor:0.1.0")
}
}
sourceSets.commonMain.dependencies {
api("uz.jahonov:ktor-monitor:0.1.0")
}
}2. Create the monitor on the iOS side of the shared code and attach it to your client:
// shared/src/iosMain/kotlin/com/example/Monitor.kt
object Monitor {
private val monitor = KtorMonitor()
val bridge = KtorMonitorBridge(monitor)
fun attach(client: HttpClient) = monitor.attach(client)
}3. Add the Swift package. In Xcode: File → Add Package Dependencies…, enter
https://github.com/JahonovAsilbek/Ktor-Monitor, pick version 0.1.0 (the same version as the
Kotlin artifacts) and add the KtorMonitorUI product to your app.
4. Connect the two and install it:
import Shared
import SwiftUI
#if DEBUG
import KtorMonitorUI
// The one line that connects the Kotlin bridge to the SwiftUI package.
extension KtorMonitorBridge: @retroactive KtorMonitorUIBridge {}
#endif
@main
struct MyApp: App {
init() {
#if DEBUG
KtorMonitorUI.install(bridge: Monitor.shared.bridge)
#endif
}
var body: some Scene {
WindowGroup { ContentView() }
}
}Shake the device (Device → Shake in the simulator) to open it. @retroactive needs Xcode 16;
drop it on older versions.
| Android | iOS | |
|---|---|---|
| From code | KtorMonitorUi.open(context) |
KtorMonitorUI.present() |
| Inside your own screen | KtorMonitorView(bridge:onClose:) |
|
| Without the shake | install(app, monitor, shakeToOpen = false) |
install(bridge:shakeToOpen: false) |
monitor.clear() (a suspend function) deletes the history.
On iOS, if the app sets its own UNUserNotificationCenterDelegate, pass notification responses to
KtorMonitorUI.handleNotificationResponse(_:) first, so a tap on the monitor's notification opens
it.
Attach the monitor after any plugin that retries requests, such as token refresh or
HttpRequestRetry. It then sees each attempt with its final headers. It installs an HttpSend
interceptor, so attaching it to a client built earlier is fine.
In an Android app with more than one process (a :remote service, a push process), install it in
the main process only: Application.onCreate runs in each of them.
val monitor = KtorMonitor(app) {
retention = Retention.OneDay
maxCalls = 500
sanitizeHeaders("Authorization", "Cookie", "Set-Cookie")
redactQueryParameters("access_token")
redactBodyFields("password", "refreshToken")
filter { request -> request.url.host != "analytics.example.com" }
onInternalError = { error -> Log.w("KtorMonitor", error) }
}| Option | Default | |
|---|---|---|
isActive |
true |
false records nothing |
retention |
OneHour |
OneHour, OneDay, OneWeek, Forever
|
maxCalls |
1000 |
the oldest go first |
maxContentLength |
250000 |
bytes kept per body; the rest is counted, not stored |
showNotification |
true |
the notification with the latest calls |
appName, appVersion
|
from the platform | named in exports |
onInternalError |
ignore | failures of the monitor itself |
filter { request -> … } |
record only the calls it accepts | |
sanitizeHeader { name -> … }, sanitizeHeaders(…)
|
replace header values | |
redactBodyFields(…) |
replace JSON keys at any depth, and form fields | |
redactQueryParameters(…) |
replace URL query parameter values |
Nothing is redacted by default: against a development backend, the tokens are what you want to
see. A JSON body that should be redacted but cannot be parsed (cut at maxContentLength, malformed)
is not recorded at all. Multipart and other bodies are recorded as they are.
The monitor records everything, tokens included, and a shake opens it. Ship it in debug or internal builds only:
debugImplementation (or a flavor's configuration), with the monitor created in a
source set release builds do not compile, as in the quick start. ktor-monitor-ui also adds the
POST_NOTIFICATIONS permission to the app's manifest, so a release build that does not depend on
it does not ask for it either.ktor-monitor only from the framework your debug or internal builds link, and keep
the Swift lines behind #if DEBUG (or the condition of your internal builds).The notification shows only its title on the lock screen, and its lines leave out query strings.
attach(client) runs on the client the app actually uses, and
that filter does not drop them.KtorMonitorBridge is unknown in Swift. The framework must export the core, and the
dependency must be api, as in step 1.Kotlin types reach Swift only through the app's own framework, whose name the SwiftUI package
cannot know. So the package talks to the monitor through KtorMonitorUIBridge: strings and
closures only, with states, effects and events as JSON. The Kotlin KtorMonitorBridge has exactly
those members, which is why one extension line connects them. The JSON contract is pinned by
fixtures in ios/KtorMonitorUI/Tests/KtorMonitorUITests/Fixtures, which the Kotlin tests write and
the Swift tests read.
./gradlew :sample:installDebug
ios/Sample/Sample.xcodeproj and run the Sample scheme. On a device, pick your team
and change the bundle identifier first. Its build phase runs Gradle, which needs a JDK that Xcode
can find (JAVA_HOME, or /usr/libexec/java_home).Both make calls against httpbin.org that show each kind of body and failure.
iosArm64, iosSimulatorArm64); Intel
simulators are not supported.The Maven group is uz.jahonov. Another library with a similar name exists
(ro.cosminmihu.ktor:ktor-monitor); the two are unrelated.
Ktor Monitor is free and open source. If it saves you time, you can buy me a coffee — it keeps the project going.
Bug reports and ideas are welcome in Issues, and a ⭐ helps others find it.
See every HTTP call your Ktor client makes, inside the app itself. Shake the device or tap the notification: the list of calls opens, with their headers, bodies, timings and errors. For Kotlin Multiplatform and Android apps, with a native UI on each platform: Compose on Android, SwiftUI on iOS.
| Android | ![]() |
![]() |
![]() |
|---|---|---|---|
| iOS | ![]() |
![]() |
1. Add the dependencies, for debug builds only:
// app/build.gradle.kts
dependencies {
debugImplementation("uz.jahonov:ktor-monitor:0.1.0")
debugImplementation("uz.jahonov:ktor-monitor-ui:0.1.0")
}2. Create the monitor and attach it to your client. Release builds do not compile src/debug,
so put it there, and give src/release the same function doing nothing:
// src/debug/kotlin/com/example/DebugTools.kt
fun installDebugTools(app: Application, client: HttpClient) {
val monitor = KtorMonitor(app)
monitor.attach(client)
KtorMonitorUi.install(app, monitor)
}
// src/release/kotlin/com/example/DebugTools.kt
fun installDebugTools(app: Application, client: HttpClient) = Unit3. Call it from Application.onCreate:
class App : Application() {
override fun onCreate() {
super.onCreate()
installDebugTools(this, httpClient)
}
}Run the app, make a call, then shake the device or tap the notification.
1. Depend on the core in the shared module, and export it from the iOS framework, so Swift sees
KtorMonitorBridge:
// shared/build.gradle.kts
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = "Shared"
export("uz.jahonov:ktor-monitor:0.1.0")
}
}
sourceSets.commonMain.dependencies {
api("uz.jahonov:ktor-monitor:0.1.0")
}
}2. Create the monitor on the iOS side of the shared code and attach it to your client:
// shared/src/iosMain/kotlin/com/example/Monitor.kt
object Monitor {
private val monitor = KtorMonitor()
val bridge = KtorMonitorBridge(monitor)
fun attach(client: HttpClient) = monitor.attach(client)
}3. Add the Swift package. In Xcode: File → Add Package Dependencies…, enter
https://github.com/JahonovAsilbek/Ktor-Monitor, pick version 0.1.0 (the same version as the
Kotlin artifacts) and add the KtorMonitorUI product to your app.
4. Connect the two and install it:
import Shared
import SwiftUI
#if DEBUG
import KtorMonitorUI
// The one line that connects the Kotlin bridge to the SwiftUI package.
extension KtorMonitorBridge: @retroactive KtorMonitorUIBridge {}
#endif
@main
struct MyApp: App {
init() {
#if DEBUG
KtorMonitorUI.install(bridge: Monitor.shared.bridge)
#endif
}
var body: some Scene {
WindowGroup { ContentView() }
}
}Shake the device (Device → Shake in the simulator) to open it. @retroactive needs Xcode 16;
drop it on older versions.
| Android | iOS | |
|---|---|---|
| From code | KtorMonitorUi.open(context) |
KtorMonitorUI.present() |
| Inside your own screen | KtorMonitorView(bridge:onClose:) |
|
| Without the shake | install(app, monitor, shakeToOpen = false) |
install(bridge:shakeToOpen: false) |
monitor.clear() (a suspend function) deletes the history.
On iOS, if the app sets its own UNUserNotificationCenterDelegate, pass notification responses to
KtorMonitorUI.handleNotificationResponse(_:) first, so a tap on the monitor's notification opens
it.
Attach the monitor after any plugin that retries requests, such as token refresh or
HttpRequestRetry. It then sees each attempt with its final headers. It installs an HttpSend
interceptor, so attaching it to a client built earlier is fine.
In an Android app with more than one process (a :remote service, a push process), install it in
the main process only: Application.onCreate runs in each of them.
val monitor = KtorMonitor(app) {
retention = Retention.OneDay
maxCalls = 500
sanitizeHeaders("Authorization", "Cookie", "Set-Cookie")
redactQueryParameters("access_token")
redactBodyFields("password", "refreshToken")
filter { request -> request.url.host != "analytics.example.com" }
onInternalError = { error -> Log.w("KtorMonitor", error) }
}| Option | Default | |
|---|---|---|
isActive |
true |
false records nothing |
retention |
OneHour |
OneHour, OneDay, OneWeek, Forever
|
maxCalls |
1000 |
the oldest go first |
maxContentLength |
250000 |
bytes kept per body; the rest is counted, not stored |
showNotification |
true |
the notification with the latest calls |
appName, appVersion
|
from the platform | named in exports |
onInternalError |
ignore | failures of the monitor itself |
filter { request -> … } |
record only the calls it accepts | |
sanitizeHeader { name -> … }, sanitizeHeaders(…)
|
replace header values | |
redactBodyFields(…) |
replace JSON keys at any depth, and form fields | |
redactQueryParameters(…) |
replace URL query parameter values |
Nothing is redacted by default: against a development backend, the tokens are what you want to
see. A JSON body that should be redacted but cannot be parsed (cut at maxContentLength, malformed)
is not recorded at all. Multipart and other bodies are recorded as they are.
The monitor records everything, tokens included, and a shake opens it. Ship it in debug or internal builds only:
debugImplementation (or a flavor's configuration), with the monitor created in a
source set release builds do not compile, as in the quick start. ktor-monitor-ui also adds the
POST_NOTIFICATIONS permission to the app's manifest, so a release build that does not depend on
it does not ask for it either.ktor-monitor only from the framework your debug or internal builds link, and keep
the Swift lines behind #if DEBUG (or the condition of your internal builds).The notification shows only its title on the lock screen, and its lines leave out query strings.
attach(client) runs on the client the app actually uses, and
that filter does not drop them.KtorMonitorBridge is unknown in Swift. The framework must export the core, and the
dependency must be api, as in step 1.Kotlin types reach Swift only through the app's own framework, whose name the SwiftUI package
cannot know. So the package talks to the monitor through KtorMonitorUIBridge: strings and
closures only, with states, effects and events as JSON. The Kotlin KtorMonitorBridge has exactly
those members, which is why one extension line connects them. The JSON contract is pinned by
fixtures in ios/KtorMonitorUI/Tests/KtorMonitorUITests/Fixtures, which the Kotlin tests write and
the Swift tests read.
./gradlew :sample:installDebug
ios/Sample/Sample.xcodeproj and run the Sample scheme. On a device, pick your team
and change the bundle identifier first. Its build phase runs Gradle, which needs a JDK that Xcode
can find (JAVA_HOME, or /usr/libexec/java_home).Both make calls against httpbin.org that show each kind of body and failure.
iosArm64, iosSimulatorArm64); Intel
simulators are not supported.The Maven group is uz.jahonov. Another library with a similar name exists
(ro.cosminmihu.ktor:ktor-monitor); the two are unrelated.
Ktor Monitor is free and open source. If it saves you time, you can buy me a coffee — it keeps the project going.
Bug reports and ideas are welcome in Issues, and a ⭐ helps others find it.