Network Monitor¶
Capture every HTTP request and response your app makes, with searchable list, method filters, and a detail view that pretty-prints headers and JSON bodies. Built-in Ktor integration; any other client plugs in via the low-level NetworkMonitorStore API.
Platforms¶
Features¶
- Searchable call list — filter by URL, host, path, or response body.
- Method chips — toggle GET / POST / PUT / DELETE / PATCH on or off.
- Color-coded status —
2xx,3xx,4xx,5xx, and pending requests render distinctly. - Request / response tabs — copyable headers, pretty-printed JSON bodies.
- Header sanitization — drop or redact
Authorization,X-Api-Key, anything you choose. - Request filtering — skip specific hosts or routes from being recorded.
- Body truncation — cap captured body length to avoid log bloat.
- Configurable retention — auto-prune calls older than your chosen
Duration.
Modules¶
| Module | Purpose |
|---|---|
:plugins:network-monitor:api |
SQLDelight data layer + NetworkMonitorStore (Paging-backed). |
:plugins:network-monitor:ui |
Compose UI + NetworkMonitorPlugin (the SidekickPlugin impl). |
:plugins:network-monitor:ktor |
NetworkMonitorKtor Ktor HttpClientPlugin (optional). |
:plugins:network-monitor:noop |
Release stub for all three above — same FQNs, empty bodies. No SQLDelight database, every recordX / install hook is a no-op. Swap in via releaseImplementation on Android or a build property on other targets. See Release builds. |
Setup¶
1. Add dependencies¶
// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation(platform("dev.parez.sidekick:bom:2026.05.17"))
// `compileOnly` keeps the real jars off Android release's runtime
// classpath, where they would collide with the noop variant.
compileOnly("dev.parez.sidekick:network-monitor-ui")
compileOnly("dev.parez.sidekick:network-monitor-ktor") // Ktor integration
}
}
}
dependencies {
implementation(platform("dev.parez.sidekick:bom:2026.05.17"))
debugImplementation("dev.parez.sidekick:shell")
releaseImplementation("dev.parez.sidekick:noop")
// Release Android: swap the recording trio (api + ui + ktor) for the
// single `network-monitor-noop` module, which exposes the same FQNs but
// strips SQLDelight and makes recordX / install hooks empty.
debugImplementation("dev.parez.sidekick:network-monitor-ui")
debugImplementation("dev.parez.sidekick:network-monitor-ktor")
releaseImplementation("dev.parez.sidekick:network-monitor-noop")
}
debugImplementation / releaseImplementation are Android-only. For Desktop / iOS / JS / Wasm, see Release builds › Non-Android targets for the property-gated swap recipe.
If you use a non-Ktor HTTP client, omit network-monitor-ktor and see Advanced › Custom HTTP client.
For multi-module apps where the calling code is in a KMP library, see Installation › Multi-module KMP app for the compileOnly / per-target split.
2. Wire into Sidekick¶
The client app owns the FAB and visibility state; Sidekick only renders the panel. See Quick Start for the full pattern:
@Composable
fun App() {
val networkPlugin = remember { NetworkMonitorPlugin() }
var sidekickVisible by remember { mutableStateOf(false) }
Box(Modifier.fillMaxSize()) {
MyAppContent()
SmallFloatingActionButton(
onClick = { sidekickVisible = true },
modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
) {
Icon(Icons.Default.BugReport, contentDescription = "Open Sidekick")
}
AnimatedVisibility(visible = sidekickVisible, /* enter, exit */) {
Sidekick(
plugins = listOf(networkPlugin),
actions = {
IconButton(onClick = { sidekickVisible = false }) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
},
)
}
}
}
3. Install on your Ktor client¶
Every request made through this client is captured automatically.
Conditional install in release builds¶
If you use the releaseImplementation("dev.parez.sidekick:network-monitor-noop") swap shown above, no further gating is needed — install(NetworkMonitorKtor) { } resolves to the noop's empty ClientPlugin in release builds, no Ktor hooks are registered, and no recordRequest calls happen. Verified by inspecting the merged release DEX: NetworkMonitorDatabase symbols are absent entirely.
If you can't use the noop swap (e.g. you ship the real network-monitor-ktor in release for parity testing, or you're on a target without an automatic swap), gate the install on build type instead:
Configuration¶
Ktor plugin DSL¶
val httpClient = HttpClient {
install(NetworkMonitorKtor) {
// Truncate captured request/response bodies past this many characters.
maxContentLength = ContentLength.Default
// Redact sensitive headers (called once per header).
sanitizeHeader { name -> name.equals("Authorization", ignoreCase = true) }
sanitizeHeader(placeholder = "<token>") { name ->
name.equals("X-Api-Key", ignoreCase = true)
}
// Exclude specific requests from being recorded.
filter { request: HttpRequestBuilder -> request.url.host == "internal.metrics.local" }
}
}
| Setting | Type | Default | Notes |
|---|---|---|---|
maxContentLength |
Int |
ContentLength.Default (65 536) |
Use ContentLength.Full (Int.MAX_VALUE) to disable truncation. |
sanitizeHeader(placeholder, predicate) |
DSL | — | Replaces matching header values with placeholder (default "***"). Call multiple times. |
filter(predicate) |
DSL | — | Predicate receives an HttpRequestBuilder. Requests where any registered predicate returns true are skipped. |
Plugin retention¶
NetworkMonitorPlugin accepts a kotlin.time.Duration. Older calls are pruned on next init.
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.days
NetworkMonitorPlugin(retentionPeriod = 24.hours)
| Example | Behaviour |
|---|---|
1.hours (default) |
Keep the last hour of calls. |
24.hours |
Keep the last day. |
7.days |
Keep the last week. |
Duration.INFINITE |
Never prune. |
The store also caps total rows at 500 (oldest pruned first), regardless of retention.
UI¶
The panel adapts to the available width:
| Width | Layout |
|---|---|
| < 600 dp | Single pane — tap a request to navigate to its detail. |
| 600 – 840 dp | Two panes at 40 / 60 split. |
| ≥ 840 dp | Two panes — list fixed at 360 dp. |
Each row shows the HTTP method badge, host, path, status code (color-coded), and duration. The detail view has Request and Response tabs with copyable headers and pretty-printed JSON. Badge and chip colors derive from the active MaterialTheme.colorScheme — see Theming › HTTP badge and status colors.
Advanced¶
Custom HTTP client¶
If you're not on Ktor, record calls manually via NetworkMonitorStore. The store is client-agnostic and shared with the Ktor plugin.
Get the store
Record each call
val callId = uuid4().toString() // unique per request
store.recordRequest(
id = callId,
url = request.url.toString(),
method = request.method,
headers = request.headers.toMap(),
body = request.body?.readText(),
timestamp = currentTimeMillis(),
)
try {
val response = yourHttpClient.execute(request)
store.recordResponse(
id = callId,
code = response.statusCode,
headers = response.headers.toMap(),
timestamp = currentTimeMillis(),
)
store.recordResponseBody(id = callId, body = response.bodyAsText())
} catch (e: Throwable) {
store.recordError(id = callId, error = e)
throw e
}
OkHttp interceptor (Android / JVM)¶
class NetworkMonitorInterceptor(
private val store: NetworkMonitorStore,
private val scope: CoroutineScope,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val callId = UUID.randomUUID().toString()
val requestTime = System.currentTimeMillis()
scope.launch {
store.recordRequest(
id = callId,
url = request.url.toString(),
method = request.method,
headers = request.headers.toMap(),
body = request.body?.let { body ->
val buffer = okio.Buffer()
body.writeTo(buffer)
buffer.readUtf8()
},
timestamp = requestTime,
)
}
return try {
val response = chain.proceed(request)
val responseTime = System.currentTimeMillis()
val bodyText = response.peekBody(Long.MAX_VALUE).string()
scope.launch {
store.recordResponse(
id = callId,
code = response.code,
headers = response.headers.toMap(),
timestamp = responseTime,
)
store.recordResponseBody(id = callId, body = bodyText)
}
response
} catch (e: IOException) {
scope.launch { store.recordError(id = callId, error = e) }
throw e
}
}
}
val store = NetworkMonitorKoinContext.getDefaultStore()
val client = OkHttpClient.Builder()
.addInterceptor(NetworkMonitorInterceptor(store, coroutineScope))
.build()
Note
OkHttp is Android/JVM-only. For Compose Multiplatform code, prefer the Ktor integration above.