
Runs an embedded HTTP server and web UI to inspect full request/response bodies, logs and custom events on a unified real-time timeline; debug-only and header-redacting.
Argus takes its name from Argus Panoptes — the hundred-eyed giant of Greek myth, set by Hera to watch over Io. The library shares the job description: see everything the app does, miss nothing.
In practice, that means: in-app debug tooling for Kotlin Multiplatform apps. Argus runs an embedded Ktor server inside debug builds and serves a desktop-class web UI on the local network — open any browser on the same Wi-Fi and inspect HTTP traffic, application logs, and custom events on a single unified timeline. Built Ktor-first (no OkHttp shim), KMP-ready, and engineered so release builds contain zero Argus classes by construction.
HttpClient plugin captures full request/response including bodies. No proxy, no certificate, no USB cable.com.lynxal.logging) interleave on one stream with source badges. No tab-switching.Authorization, Cookie, Set-Cookie, Proxy-Authorization redacted by default; configurable.| Attribute | Value |
|---|---|
| Version | 1.0.0 |
| Platforms | Android, iOS (Ktor-host apps) |
minSdk |
24 |
compileSdk / targetSdk
|
36 |
| Kotlin | 2.2.0 |
| Ktor | 3.2.0 |
[!WARNING] Argus is a debug tool. It must never ship in a release build.
- Argus binds a local TCP port and serves a web UI with full request/response bodies and application logs. In a production app this is a severe security risk: any device on the same network can read tokens, PII, and internal traffic.
- Argus is published with no release-safe shim and no no-op variant — by design. The integration pattern below makes release inclusion physically impossible when followed: a release build that imports
com.lynxal.argus.*will not compile, and one that links it transitively will be caught by:sample:verifyReleaseHasNoArgus(CI gate).- Use
debugImplementation(and optionallystagingImplementation). Neverimplementation,releaseImplementation, orapi— all four leak Argus into the release APK.
The integration pattern (next section) is non-negotiable. It is what keeps the warning above true.
Every code block below is copied verbatim from :sample, which is gated on every PR by :sample:verifyReleaseHasNoArgus. If the sample builds, this README is correct.
app/build.gradle.kts:
dependencies {
debugImplementation("com.lynxal.argus:argus-android:1.0.0")
// stagingImplementation("com.lynxal.argus:argus-android:1.0.0") // optional, see §5
}[!IMPORTANT] Do not use
implementation,api, orreleaseImplementation. All four pull Argus into the release APK.
A plain Kotlin interface that names every capability the debug tool exposes. The interface lives in src/main/ (or src/androidMain/ for KMP modules) and imports nothing from com.lynxal.argus.* — that's what makes the release source set able to provide a no-op without compile errors.
src/androidMain/kotlin/com/example/yourapp/debug/DebugTools.kt:
package com.lynxal.argus.sample.debug
import io.ktor.client.HttpClient
import kotlinx.coroutines.flow.StateFlow
interface DebugTools {
fun buildHttpClient(): HttpClient
fun installLogging()
fun observeArgusUrl(): StateFlow<String?>
/** Why the inspector isn't up, or null. A String so no Argus type crosses the seam. */
fun observeArgusError(): StateFlow<String?>
/** Stop the inspector and release its port. Safe to call twice, or before bind. No-op in release. */
fun stopArgus()
/** Emit a CustomEvent through the sample's bus. No-op in release. */
fun publishCustom(source: String, label: String, payload: String)
/** Fire an OkHttp request through Argus's interceptor. No-op in release. */
fun fireOkHttpCall(url: String)
/** Fire an HttpURLConnection request wrapped by Argus. No-op in release. */
fun fireUrlConnectionCall(url: String)
/**
* Fire two HTTP calls back-to-back inside one ArgusCorrelationId scope so the
* resulting events share a correlation id. Lives behind the debug seam because
* ArgusCorrelationId is in `:argus-core`, which release variants must not link.
* No-op in release.
*/
fun fireCorrelatedPair(first: String, second: String)
}This is where Argus is started and wired into the Ktor HttpClient and the logger.
src/androidDebug/kotlin/com/example/yourapp/debug/DebugToolsImpl.kt:
package com.lynxal.argus.sample.debug
import android.app.Application
import com.lynxal.argus.android.Argus
import com.lynxal.argus.android.ArgusHandle
import com.lynxal.argus.correlation.withCorrelation
import com.lynxal.argus.logging.ArgusLoggerDelegate
import com.lynxal.argus.model.Direction
import com.lynxal.argus.model.publishCustom
import com.lynxal.argus.okhttp.ArgusOkHttpConfig
import com.lynxal.argus.okhttp.ArgusOkHttpInterceptor
import com.lynxal.argus.urlconnection.ArgusUrlConnection
import com.lynxal.argus.urlconnection.ArgusUrlConnectionConfig
import com.lynxal.logging.DebugLoggerImplementation
import com.lynxal.logging.LogLevel
import com.lynxal.logging.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import com.lynxal.argus.ktor.Argus as ArgusPlugin
class DebugToolsImpl(private val app: Application) : DebugTools {
private val argus: ArgusHandle = Argus.start(app) {
port = 8787
maxBodyBytes = 262_144L
}
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(
ArgusOkHttpInterceptor(
argus.eventBus,
ArgusOkHttpConfig().apply { maxBodyBytes = 262_144L },
),
)
.build()
}
private val ktorClient: HttpClient by lazy {
HttpClient(CIO) {
install(ArgusPlugin) {
eventBus = argus.eventBus
maxBodyBytes = 262_144L
}
install(ContentNegotiation) {
json()
}
}
}
override fun buildHttpClient(): HttpClient = ktorClient
override fun installLogging() {
Logger.minLevel = LogLevel.Verbose
Logger.add(DebugLoggerImplementation())
Logger.add(ArgusLoggerDelegate(argus.eventBus))
}
override fun observeArgusUrl(): StateFlow<String?> = argus.url
override fun stopArgus() {
argus.stop()
}
override fun publishCustom(source: String, label: String, payload: String) {
argus.eventBus.publishCustom(
source = source,
label = label,
direction = Direction.NONE,
payload = payload,
)
}
override fun fireOkHttpCall(url: String) {
ioScope.launch {
runCatching {
okHttpClient.newCall(Request.Builder().url(url).build()).execute().use {
it.body?.string()
}
}
}
}
override fun fireUrlConnectionCall(url: String) {
ioScope.launch {
runCatching {
val raw = URL(url).openConnection() as HttpURLConnection
val cfg = ArgusUrlConnectionConfig().apply { maxBodyBytes = 262_144L }
val conn = ArgusUrlConnection.wrap(raw, argus.eventBus, cfg)
try {
conn.connect()
conn.inputStream.use { it.readBytes() }
} finally {
conn.disconnect()
}
}
}
}
override fun fireCorrelatedPair(first: String, second: String) {
ioScope.launch {
withCorrelation {
val logger = Logger.tag("Argus sample")
logger.info { message = "correlated-pair: starting" }
runCatching { ktorClient.get(first) }
logger.info { message = "correlated-pair: first done, firing second" }
runCatching { ktorClient.get(second) }
logger.info { message = "correlated-pair: done" }
}
}
}
}A no-op that mirrors the same shape. The leading invariant comment is important — keep it:
src/androidRelease/kotlin/com/example/yourapp/debug/DebugToolsImpl.kt:
// Invariant: this file must not import anything from com.lynxal.argus.*
// Enforced by :sample:verifyReleaseHasNoArgus (dexdump the release APK for
// com/lynxal/argus/, io/ktor/server/, com/lynxal/argus/webui/ — fail if any are present).
package com.lynxal.argus.sample.debug
import android.app.Application
import com.lynxal.logging.DebugLoggerImplementation
import com.lynxal.logging.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class DebugToolsImpl(@Suppress("unused") private val app: Application) : DebugTools {
private val empty: StateFlow<String?> = MutableStateFlow<String?>(null).asStateFlow()
override fun buildHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json()
}
}
override fun installLogging() {
Logger.add(DebugLoggerImplementation())
}
override fun observeArgusUrl(): StateFlow<String?> = empty
override fun stopArgus() {
// no-op in release
}
override fun publishCustom(source: String, label: String, payload: String) {
// no-op in release
}
override fun fireOkHttpCall(url: String) {
// no-op in release
}
override fun fireUrlConnectionCall(url: String) {
// no-op in release
}
override fun fireCorrelatedPair(first: String, second: String) {
// no-op in release
}
}Application code calls only DebugTools methods. The build variant decides which DebugToolsImpl is on the classpath.
src/androidMain/kotlin/com/example/yourapp/SampleApp.kt:
package com.lynxal.argus.sample
import android.app.Application
import com.lynxal.argus.sample.debug.DebugTools
import com.lynxal.argus.sample.debug.DebugToolsImpl
import io.ktor.client.HttpClient
class SampleApp : Application() {
lateinit var debugTools: DebugTools
private set
lateinit var httpClient: HttpClient
private set
override fun onCreate() {
super.onCreate()
debugTools = DebugToolsImpl(this)
debugTools.installLogging()
httpClient = debugTools.buildHttpClient()
}
}That's the full integration. Run a debug build, hit any HTTP endpoint, and Argus is capturing.
Every code block below is copied verbatim from :sample, which is gated by :sample:verifyIosReleaseHasNoArgus — an xcodebuild -configuration Release followed by a symbol scan of the produced framework. If the sample builds, this README is correct.
The iOS seam works the same way as Android (interface in shared code, real impl in a debug-only source dir, no-op impl in a release source dir) but the variant selection is driven by a Gradle property (-PargusEnabled) that the Xcode build phase script flips based on $CONFIGURATION instead of by the Android build type.
[!IMPORTANT] Argus iOS captures Ktor
HttpClienttraffic only. URLSession / Alamofire / native networking interception is not supported — your iOS app must use Ktor for HTTP if you want it on the timeline.
If your iOS app does not use Kotlin Multiplatform, consume Argus as an XCFramework via SPM:
https://github.com/lynxal/KMM-Argus.ArgusIOS library to your debug app target.#if DEBUG (or split debug/release schemes) so the released app does not link Argus:#if DEBUG
import ArgusIOS
let handle = Argus.shared.start { config in
config.port = 8787
}
#endifThe XCFramework is built by Gradle (./gradlew :argus-ios:assembleArgus-iosReleaseXCFramework) and published as a release asset on each Argus release. KMP-based apps should keep using implementation("com.lynxal.argus:argus-ios:1.0.0") from Maven Central — the steps below describe that path.
In your sample's build.gradle.kts, add iOS targets and a shared framework. Read the argusEnabled property at config time, conditionally add :argus-ios to iosMain deps, and swap the source dir between an enabled and disabled impl:
val argusEnabled: Boolean =
(findProperty("argusEnabled") as? String)?.toBoolean() ?: false
kotlin {
androidTarget { /* … */ }
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = "Sample"
isStatic = true
}
}
applyDefaultHierarchyTemplate()
sourceSets {
val iosMain by getting {
kotlin.srcDir(
if (argusEnabled) "src/iosArgusEnabledMain/kotlin"
else "src/iosArgusDisabledMain/kotlin"
)
dependencies {
implementation(libs.ktor.client.darwin)
if (argusEnabled) implementation(projects.argusIos)
}
}
}
}In your iosApp.xcodeproj, the Compile Kotlin Framework build phase passes -PargusEnabled based on $CONFIGURATION:
cd "$SRCROOT/../.."
if [ "$CONFIGURATION" = "Debug" ]; then
ARGUS_ENABLED=true
else
ARGUS_ENABLED=false
fi
./gradlew :sample:embedAndSignAppleFrameworkForXcode "-PargusEnabled=$ARGUS_ENABLED"The Xcode app target also needs OTHER_LDFLAGS = -lsqlite3 (Argus uses SqlDelight's NativeSqliteDriver for optional event persistence; the host app supplies the system sqlite link).
The interface defined in §4 Step 2 lives in commonMain/ and works for both Android and iOS — the Android impls under src/androidDebug/ + src/androidRelease/ and the iOS impls under src/iosArgusEnabledMain/ + src/iosArgusDisabledMain/ all satisfy the same shape. No duplication.
package com.lynxal.argus.sample.debug
import com.lynxal.argus.correlation.withCorrelation
import com.lynxal.argus.ios.Argus
import com.lynxal.argus.ios.ArgusHandle
import com.lynxal.argus.logging.ArgusLoggerDelegate
import com.lynxal.argus.model.Direction
import com.lynxal.argus.model.publishCustom
import com.lynxal.logging.DebugLoggerImplementation
import com.lynxal.logging.LogLevel
import com.lynxal.logging.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import com.lynxal.argus.ktor.Argus as ArgusPlugin
class DebugToolsImpl : DebugTools {
private val argus: ArgusHandle = Argus.start {
port = 8787
maxBodyBytes = 262_144L
}
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val ktorClient: HttpClient by lazy {
HttpClient(Darwin) {
install(ArgusPlugin) {
eventBus = argus.eventBus
maxBodyBytes = 262_144L
}
install(ContentNegotiation) {
json()
}
}
}
override fun buildHttpClient(): HttpClient = ktorClient
override fun installLogging() {
Logger.minLevel = LogLevel.Verbose
Logger.add(DebugLoggerImplementation())
Logger.add(ArgusLoggerDelegate(argus.eventBus))
}
override fun observeArgusUrl(): StateFlow<String?> = argus.url
override fun stopArgus() {
argus.stop()
}
override fun publishCustom(source: String, label: String, payload: String) {
argus.eventBus.publishCustom(
source = source,
label = label,
direction = Direction.NONE,
payload = payload,
)
}
override fun fireOkHttpCall(url: String) {
// OkHttp engine is JVM-only; no iOS counterpart.
}
override fun fireUrlConnectionCall(url: String) {
// HttpURLConnection is JVM-only; no iOS counterpart.
}
override fun fireCorrelatedPair(first: String, second: String) {
ioScope.launch {
withCorrelation {
val logger = Logger.tag("Argus sample")
logger.info { message = "correlated-pair: starting" }
runCatching { ktorClient.get(first) }
logger.info { message = "correlated-pair: first done, firing second" }
runCatching { ktorClient.get(second) }
logger.info { message = "correlated-pair: done" }
}
}
}
}The leading invariant comment is important — keep it.
// Invariant: this file must not import anything from com.lynxal.argus.*
// Enforced by :sample:verifyIosReleaseHasNoArgus (xcodebuild Release then nm/strings
// on the produced framework binary — fails if any com.lynxal.argus., kfun:com.lynxal.argus.,
// io.ktor.server., ArgusServer, or ArgusEventBus symbol is present).
package com.lynxal.argus.sample.debug
import com.lynxal.logging.DebugLoggerImplementation
import com.lynxal.logging.LogLevel
import com.lynxal.logging.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class DebugToolsImpl : DebugTools {
private val empty: StateFlow<String?> = MutableStateFlow<String?>(null).asStateFlow()
override fun buildHttpClient(): HttpClient = HttpClient(Darwin)
override fun installLogging() { Logger.add(DebugLoggerImplementation()) }
override fun observeArgusUrl(): StateFlow<String?> = empty
override fun stopArgus() {}
override fun publishCustom(source: String, label: String, payload: String) {}
override fun fireOkHttpCall(url: String) {}
override fun fireUrlConnectionCall(url: String) {}
override fun fireCorrelatedPair(first: String, second: String) {}
}In iosMain/, expose a single MainViewController() that constructs the right DebugToolsImpl (selected by source-dir swap) and wraps the Compose UI in a UIKit view controller:
package com.lynxal.argus.sample
import androidx.compose.ui.window.ComposeUIViewController
import com.lynxal.argus.sample.debug.DebugToolsImpl
import com.lynxal.argus.sample.ui.App
import platform.UIKit.UIViewController
fun MainViewController(): UIViewController {
val tools = DebugToolsImpl()
tools.installLogging()
return ComposeUIViewController {
App(
httpClient = tools.buildHttpClient(),
argusUrl = tools.observeArgusUrl(),
// …callbacks delegate to tools
)
}
}Swift entry-point (iosApp/iOSApp.swift):
import SwiftUI
import Sample
@main
struct ArgusSampleApp: App {
var body: some Scene {
WindowGroup { ContentView().ignoresSafeArea() }
}
}
struct ContentView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.MainViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}The Swift app calls only MainViewControllerKt.MainViewController() — it never imports com.lynxal.argus.*. Both Swift and Kotlin uphold the seam.
Run the iOS gate locally any time you change the build-phase script or seam wiring:
./gradlew :sample:verifyIosReleaseHasNoArgusIt runs xcodebuild -configuration Release -destination 'generic/platform=iOS Simulator' (no signing identity required), then scans the produced Sample.framework binary with strings for forbidden symbol fragments (kfun:com.lynxal.argus., io.ktor.server., ArgusServer, ArgusEventBus). The sample's own com.lynxal.argus.sample.* symbols are whitelisted.
[!NOTE] The iOS framework size grows considerably when Argus is linked (Debug ≈ 65 MB with debug symbols, ≈ 17 MB stripped Release). This is fine for a debug-only artifact — the Release framework, which is what ships, contains none of it.
Argus does not define a staging build type — that's a consumer concern. If your app has a staging variant and you want Argus there too:
staging build type in your app/build.gradle.kts (typically initWith debug).stagingImplementation("com.lynxal.argus:argus-android:1.0.0").src/staging/kotlin/.../debug/DebugToolsImpl.kt mirroring the debug source-set impl from §4.The same source-set seam pattern works for any number of variants. What it never does is leak Argus into release.
When Argus.start() succeeds, it logs the URL to logcat:
I/Argus: Argus listening on http://192.168.1.42:8787
Filter logcat for Argus and you'll see it on every debug launch. Open that URL in any browser on the same Wi-Fi and the inspector loads.
The URL is also exposed reactively:
debugTools.observeArgusUrl().collect { url ->
// show in a debug-only overlay or share sheet
}Surface startupError too. If the server can't bind — a pinned port already taken is the usual
cause — url simply stays null, which is indistinguishable from "still starting". ArgusHandle
exposes the reason as a second flow, so plumb it through the same seam and show it next to the URL:
// debug DebugToolsImpl
override fun observeArgusError(): StateFlow<String?> = argus.startupError
.map { it?.message }
.stateIn(scope, SharingStarted.Eagerly, null)A bind failure never takes the host app down; it is reported and nothing else. See
§12 for the port-conflict case and portFallback.
If logcat isn't handy (Canvas Hub firmware, headless device), enter the device's LAN IP and the configured port directly in the browser.
Event list. Single-column stream with source badge (HTTP/LOG/CUSTOM), method or log level, status pill, primary text (host in muted, path in primary), and meta (timestamp). Compact (28 px) and comfy (32 px) row densities. Keyboard navigation moves a 2 px focus rail down the left edge.
Detail tabs. The right pane in split view (above) shows a tabbed detail per event. HTTP events: Overview · Headers · Request · Response · Timing · cURL. Log events: Overview · Context · Stack. Custom events: Overview · Payload. Bodies render as syntax-highlighted JSON, plain text, hex+ASCII, or image preview based on content type.
Filters. Toggle source (HTTP/LOG/CUSTOM), method (GET/POST/PUT/PATCH/DELETE/OTHER), status class (2xx/3xx/4xx/5xx/ERR), and log level (ERROR/WARN/INFO/DEBUG/VERB) as filled chips. Add text filters for host, tag, and free-text contains. Active filters are tinted in the source's color.
Waterfall. Time axis with per-event tracks. HTTP requests stack as Connect / Wait / Download segments, each tinted by status; errored requests render as a dashed red bar. Log and custom events show as 2 px ticks at their timestamp. Zoom in/out from the header.
Export. Copy any event as cURL. Headers and bodies copy individually. The whole stream exports as JSON.
Keyboard shortcuts. / focuses search. j / k navigate the event list. 1 / 2 / 3 switch List / Split / Waterfall views. p pauses live ingest. ? opens the shortcut overlay.
Argus has two config surfaces: server-side (Argus.start { ... }) for the inspector server, and client-side (per-engine capture plugins) for what gets recorded.
| Option | Default | Description |
|---|---|---|
port |
0 (OS-assigned) |
TCP port for the embedded server. Pin (e.g. 8787) for a stable URL. If a pinned port is already in use, the server doesn't come up and the reason lands on ArgusHandle.startupError — your app keeps running either way. |
portFallback |
false |
When a pinned port can't be bound, rebind on an OS-assigned port instead of giving up. Off by default so a fixed-port setup never silently moves; ArgusHandle.url always reports the port actually bound. |
maxEvents |
500 |
Ring-buffer size. Older events are dropped beyond this. |
maxBodyBytes |
1_000_000 (1 MB) |
Per-body capture cap. Bodies larger than this are truncated. |
redactHeaders |
["Authorization", "Cookie", "Set-Cookie", "Proxy-Authorization"] |
HTTP header names whose values are replaced with ***redacted*** before capture. |
corsDevOrigins |
["http://localhost:5173"] |
Extra CORS origins for the dev web UI. The bundled production UI is served same-origin and needs no entry here. Set to emptyList() in any non-debug context to skip the CORS plugin install. |
persist |
false |
Persist events to disk so they survive process restarts. On next Argus.start(), the previous session's events are restored into the in-memory ring (capped at maxEvents). The UI does not surface older sessions — persist keeps the timeline alive across restarts, not a full archive. |
persistMaxSizeMb |
100 |
Soft cap on on-disk payload size (MB). Whichever fires first with persistMaxAgeDays prunes the oldest persisted events. |
persistMaxAgeDays |
7 |
Soft cap on persisted-event age (days). Whichever fires first with persistMaxSizeMb. |
Example (from :sample):
Argus.start(application) {
port = 8787
maxBodyBytes = 262_144L // 256 KB
}Argus.start() returns an ArgusHandle. That handle is the lifecycle: it carries the bound
URL, the failure reason, the event bus your capture plugins write into, and stop().
val argus: ArgusHandle = Argus.start(application) { port = 8787 }
argus.url // StateFlow<String?> — "http://192.168.1.42:8787" once bound, else null
argus.startupError // StateFlow<Throwable?> — why it isn't up, else null
argus.eventBus // wire into ArgusPlugin / ArgusOkHttpInterceptor / ArgusLoggerDelegate
argus.stop() // stop the server, release the portstart() is non-suspending and returns immediately. The socket binds on a background
dispatcher, so url is null for a short while after start() returns. That's why it's a
StateFlow and not a String — observe it, don't read it once. Call start() from your
debug-only Application.onCreate() (Android) or app init (iOS).
stop() releases the port and is safe to call anywhere. Specifically, it is:
It resets url and startupError to null. A stopped handle is spent: to run the inspector
again, call Argus.start() for a fresh handle rather than reusing the old one.
One caveat worth knowing: stop() blocks the calling thread for up to ~1.1 s while the engine
drains in-flight requests. If you're stopping from onDestroy() or anywhere on the main thread
and that matters to you, move the call to a background dispatcher.
The guide above starts Argus once in Application.onCreate(), which is what most apps want. If you
instead want to start and stop it from a debug menu, there's one thing to get right: every
Argus.start() creates a fresh server with a fresh event bus and a fresh ring buffer, and a
stopped handle is spent. Wire your capture plugins straight to handle.eventBus and after one
stop/start cycle they'll still be publishing into the previous run's buffer — the inspector comes
back up and shows nothing.
Give the plugins a stable bus that forwards to whichever run is current:
private class SwitchableEventBus : ArgusEventBus {
@Volatile var target: ArgusEventBus? = null
override fun publish(event: ArgusEvent) {
target?.publish(event) // dropped while the inspector is stopped
}
}Wire the plugins to that once, then on start set bus.target = handle.eventBus and on stop set it
back to null. :sample does exactly this — see its debug DebugToolsImpl for the whole thing,
including mirroring url/startupError onto flows that outlive an individual run.
Argus must never take the host app down. It is a debugging aid — a failure inside it is never worth a crash in your app. Concretely, and covered by tests on both Android and iOS:
| Situation | What happens |
|---|---|
| Pinned port already in use |
startupError is set, url stays null, app runs on. With portFallback = true, rebinds on a free port instead. |
| Any other bind failure | Same — the handling is errno-agnostic. |
| Engine dies after a successful bind | Reported to startupError. |
| The persistence DB can't be opened or migrated | Logged, and Argus continues in-memory with persist effectively off. |
Anything at all during stop()
|
Logged and swallowed. |
The one thing that does throw is ArgusServer.boundPort when read before a successful start —
it's a programming error, not a runtime condition. Read handle.url instead.
This is a contract, not a best effort, so surface startupError in your debug UI. Because
Argus fails quietly by design, a silent failure looks identical to "still starting" unless you
show the reason. :sample does this next to the URL — see
§10 — and §7 has the snippet.
These options live on each engine's plugin/interceptor config: Ktor install(ArgusPlugin) { ... }, OkHttp ArgusInterceptor's ArgusOkHttpConfig, and HttpURLConnection's ArgusUrlConnectionConfig. The Ktor shape is shown; the others mirror it field-for-field.
| Option | Default | Description |
|---|---|---|
eventBus |
NoopEventBus |
Sink for captured events. Set to argusHandle.eventBus so captures land in the inspector. |
maxBodyBytes |
1_000_000 (1 MB) |
Per-body capture cap. Bodies larger than this are truncated. |
redactHeaders |
(same default set as server) | Headers whose values are replaced with ***redacted*** before capture. |
captureRequestBody |
true |
Capture request bodies. Set to false to record only metadata. |
captureResponseBody |
true |
Capture response bodies. Set to false to record only metadata. |
fullBodyHosts |
emptySet() |
Hosts whose bodies bypass maxBodyBytes and are captured in full. Case-insensitive on URL host (no port, no scheme). Practical ceiling per body is ~2 GB (Int.MAX_VALUE) because captures are held in a single ByteArray; larger payloads are truncated and the event reports truncatedTotalBytes. Use sparingly. |
:sample is the canonical reference for both platforms. The same KMP module produces the Android APK and the iOS framework; Compose Multiplatform renders the shared UI on both.
Android:
git clone https://github.com/lynxal/KMM-Argus.git
cd argus
./gradlew :sample:installDebugLaunch on a device or emulator, hit a couple of buttons, and open the URL from logcat. You should see Argus working in two minutes.
The sample does not start Argus automatically. A status line at the top always says which state it's in — Argus: stopped, Argus: starting…, Argus: failed to start (with the reason beneath), or the bound URL — and a single button toggles between Start Argus and Stop Argus. That exercises the whole lifecycle from §9.3, including repeated stop/start cycles, which is why the sample routes its capture plugins through a switchable bus rather than holding one run's eventBus.
To watch the port-conflict path, get something else onto 8787 first — launch the sample twice, or adb forward tcp:8787 tcp:8787 — then press Start Argus: the status line reports the conflict and the app keeps running.
iOS: open sample/iosApp/iosApp.xcodeproj in Xcode and run on a simulator or device with the Debug scheme. The buttons are the same as Android; OkHttp and HttpURLConnection demos are no-ops on iOS (the engines are JVM-only).
Both gates run as part of :sample:check. Run them locally any time you change variant wiring:
./gradlew :sample:verifyReleaseHasNoArgus # Android: dexdumps the release APK
./gradlew :sample:verifyIosReleaseHasNoArgus # iOS: scans the Release framework binaryflowchart LR
consumer["consumer app<br/>(debug variant)"]
android["argus-android<br/><i>Android entry point</i>"]
ios["argus-ios<br/><i>iOS entry point</i>"]
server["argus-server-core<br/><i>Embedded Ktor server</i>"]
bundle["argus-webui-bundle<br/><i>Pre-built SPA (resource)</i>"]
core["argus-core<br/><i>Event model + Ktor capture plugin</i>"]
okhttp["argus-okhttp<br/><i>OkHttp interceptor</i>"]
urlconn["argus-urlconnection<br/><i>HttpURLConnection wrapper</i>"]
webui["argus-webui<br/><i>UI source (build-time)</i>"]
consumer --> android
consumer --> ios
consumer -.-> okhttp
consumer -.-> urlconn
android --> server
ios --> server
android --> core
ios --> core
okhttp --> core
urlconn --> core
server --> core
server -. bundles .- bundle
webui -. compiled into .- bundle| Module | Coordinates | Purpose |
|---|---|---|
argus-core |
com.lynxal.argus:argus-core:1.0.0 |
Shared model, ArgusClientPlugin (Ktor capture), event bus, redaction. |
argus-server-core |
com.lynxal.argus:argus-server-core:1.0.0 |
Embedded Ktor server: REST + WebSocket endpoints, event dispatcher, ArgusConfig. |
argus-webui-bundle |
com.lynxal.argus:argus-webui-bundle:1.0.0 |
Pre-built SPA shipped as a JVM resource the server statically serves. |
argus-android |
com.lynxal.argus:argus-android:1.0.0 |
Android entry point: Argus.start(), ArgusHandle, ArgusConfigBuilder. |
argus-ios |
com.lynxal.argus:argus-ios:1.0.0 |
iOS entry point for Apple targets: Argus.start(), ArgusHandle, ArgusConfigBuilder. Also published as an XCFramework via Swift Package Manager (see §5). |
argus-okhttp |
com.lynxal.argus:argus-okhttp:1.0.0 |
OkHttp Interceptor capture for non-Ktor JVM HTTP. |
argus-urlconnection |
com.lynxal.argus:argus-urlconnection:1.0.0 |
HttpURLConnection capture wrapper for legacy JVM HTTP. |
Why debug-only? See §3. The summary: the embedded server is a production-grade attack surface, and the seam-pattern source-set split (with the verifyReleaseHasNoArgus CI gate) is the only integration shape we support. There is no no-op artifact, by design — a missing release-side DebugToolsImpl is a build error, which is the desired failure mode.
Can't connect from desktop. Most common causes, in order:
adb reverse tcp:8787 tcp:8787 over USB.curl http://<device-ip>:8787/api/events.port = 8787 and another process owns it — a second instance of your app, most commonly — the server doesn't start. Your app is unaffected; the bind error shows up on ArgusHandle.startupError and in logcat as E/Argus: Argus start failed. Set portFallback = true to rebind on a free port instead, drop the pin (port = 0), or pick a different port. See §9.3 for the full contract.Release build fails to compile / link. Confirm src/release/.../debug/DebugToolsImpl.kt exists and has the same shape as src/debug/.../debug/DebugToolsImpl.kt but zero com.lynxal.argus.* imports. The release-source-set file is what makes the variant compile when Argus is absent.
Release APK contains Argus classes. Run :sample:verifyReleaseHasNoArgus (or the equivalent in your app) for the canonical diagnostic. Then:
./gradlew :app:dependencies --configuration releaseRuntimeClasspath | grep -i argusIf anything appears, you have an implementation, api, or releaseImplementation line pulling Argus in transitively (often via a shared library that itself uses implementation instead of debugImplementation). Convert it to debugImplementation.
Something else. :sample is the canonical working integration. Diff your variant wiring against it.
Issues and pull requests are welcome via GitHub. There is no CONTRIBUTING.md yet — the short version: fork, branch, run ./gradlew check :sample:verifyReleaseHasNoArgus, open a PR.
License: MIT — matching the licenses block every module's POM publishes to Maven Central.
Argus takes its name from Argus Panoptes — the hundred-eyed giant of Greek myth, set by Hera to watch over Io. The library shares the job description: see everything the app does, miss nothing.
In practice, that means: in-app debug tooling for Kotlin Multiplatform apps. Argus runs an embedded Ktor server inside debug builds and serves a desktop-class web UI on the local network — open any browser on the same Wi-Fi and inspect HTTP traffic, application logs, and custom events on a single unified timeline. Built Ktor-first (no OkHttp shim), KMP-ready, and engineered so release builds contain zero Argus classes by construction.
HttpClient plugin captures full request/response including bodies. No proxy, no certificate, no USB cable.com.lynxal.logging) interleave on one stream with source badges. No tab-switching.Authorization, Cookie, Set-Cookie, Proxy-Authorization redacted by default; configurable.| Attribute | Value |
|---|---|
| Version | 1.0.0 |
| Platforms | Android, iOS (Ktor-host apps) |
minSdk |
24 |
compileSdk / targetSdk
|
36 |
| Kotlin | 2.2.0 |
| Ktor | 3.2.0 |
[!WARNING] Argus is a debug tool. It must never ship in a release build.
- Argus binds a local TCP port and serves a web UI with full request/response bodies and application logs. In a production app this is a severe security risk: any device on the same network can read tokens, PII, and internal traffic.
- Argus is published with no release-safe shim and no no-op variant — by design. The integration pattern below makes release inclusion physically impossible when followed: a release build that imports
com.lynxal.argus.*will not compile, and one that links it transitively will be caught by:sample:verifyReleaseHasNoArgus(CI gate).- Use
debugImplementation(and optionallystagingImplementation). Neverimplementation,releaseImplementation, orapi— all four leak Argus into the release APK.
The integration pattern (next section) is non-negotiable. It is what keeps the warning above true.
Every code block below is copied verbatim from :sample, which is gated on every PR by :sample:verifyReleaseHasNoArgus. If the sample builds, this README is correct.
app/build.gradle.kts:
dependencies {
debugImplementation("com.lynxal.argus:argus-android:1.0.0")
// stagingImplementation("com.lynxal.argus:argus-android:1.0.0") // optional, see §5
}[!IMPORTANT] Do not use
implementation,api, orreleaseImplementation. All four pull Argus into the release APK.
A plain Kotlin interface that names every capability the debug tool exposes. The interface lives in src/main/ (or src/androidMain/ for KMP modules) and imports nothing from com.lynxal.argus.* — that's what makes the release source set able to provide a no-op without compile errors.
src/androidMain/kotlin/com/example/yourapp/debug/DebugTools.kt:
package com.lynxal.argus.sample.debug
import io.ktor.client.HttpClient
import kotlinx.coroutines.flow.StateFlow
interface DebugTools {
fun buildHttpClient(): HttpClient
fun installLogging()
fun observeArgusUrl(): StateFlow<String?>
/** Why the inspector isn't up, or null. A String so no Argus type crosses the seam. */
fun observeArgusError(): StateFlow<String?>
/** Stop the inspector and release its port. Safe to call twice, or before bind. No-op in release. */
fun stopArgus()
/** Emit a CustomEvent through the sample's bus. No-op in release. */
fun publishCustom(source: String, label: String, payload: String)
/** Fire an OkHttp request through Argus's interceptor. No-op in release. */
fun fireOkHttpCall(url: String)
/** Fire an HttpURLConnection request wrapped by Argus. No-op in release. */
fun fireUrlConnectionCall(url: String)
/**
* Fire two HTTP calls back-to-back inside one ArgusCorrelationId scope so the
* resulting events share a correlation id. Lives behind the debug seam because
* ArgusCorrelationId is in `:argus-core`, which release variants must not link.
* No-op in release.
*/
fun fireCorrelatedPair(first: String, second: String)
}This is where Argus is started and wired into the Ktor HttpClient and the logger.
src/androidDebug/kotlin/com/example/yourapp/debug/DebugToolsImpl.kt:
package com.lynxal.argus.sample.debug
import android.app.Application
import com.lynxal.argus.android.Argus
import com.lynxal.argus.android.ArgusHandle
import com.lynxal.argus.correlation.withCorrelation
import com.lynxal.argus.logging.ArgusLoggerDelegate
import com.lynxal.argus.model.Direction
import com.lynxal.argus.model.publishCustom
import com.lynxal.argus.okhttp.ArgusOkHttpConfig
import com.lynxal.argus.okhttp.ArgusOkHttpInterceptor
import com.lynxal.argus.urlconnection.ArgusUrlConnection
import com.lynxal.argus.urlconnection.ArgusUrlConnectionConfig
import com.lynxal.logging.DebugLoggerImplementation
import com.lynxal.logging.LogLevel
import com.lynxal.logging.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import com.lynxal.argus.ktor.Argus as ArgusPlugin
class DebugToolsImpl(private val app: Application) : DebugTools {
private val argus: ArgusHandle = Argus.start(app) {
port = 8787
maxBodyBytes = 262_144L
}
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(
ArgusOkHttpInterceptor(
argus.eventBus,
ArgusOkHttpConfig().apply { maxBodyBytes = 262_144L },
),
)
.build()
}
private val ktorClient: HttpClient by lazy {
HttpClient(CIO) {
install(ArgusPlugin) {
eventBus = argus.eventBus
maxBodyBytes = 262_144L
}
install(ContentNegotiation) {
json()
}
}
}
override fun buildHttpClient(): HttpClient = ktorClient
override fun installLogging() {
Logger.minLevel = LogLevel.Verbose
Logger.add(DebugLoggerImplementation())
Logger.add(ArgusLoggerDelegate(argus.eventBus))
}
override fun observeArgusUrl(): StateFlow<String?> = argus.url
override fun stopArgus() {
argus.stop()
}
override fun publishCustom(source: String, label: String, payload: String) {
argus.eventBus.publishCustom(
source = source,
label = label,
direction = Direction.NONE,
payload = payload,
)
}
override fun fireOkHttpCall(url: String) {
ioScope.launch {
runCatching {
okHttpClient.newCall(Request.Builder().url(url).build()).execute().use {
it.body?.string()
}
}
}
}
override fun fireUrlConnectionCall(url: String) {
ioScope.launch {
runCatching {
val raw = URL(url).openConnection() as HttpURLConnection
val cfg = ArgusUrlConnectionConfig().apply { maxBodyBytes = 262_144L }
val conn = ArgusUrlConnection.wrap(raw, argus.eventBus, cfg)
try {
conn.connect()
conn.inputStream.use { it.readBytes() }
} finally {
conn.disconnect()
}
}
}
}
override fun fireCorrelatedPair(first: String, second: String) {
ioScope.launch {
withCorrelation {
val logger = Logger.tag("Argus sample")
logger.info { message = "correlated-pair: starting" }
runCatching { ktorClient.get(first) }
logger.info { message = "correlated-pair: first done, firing second" }
runCatching { ktorClient.get(second) }
logger.info { message = "correlated-pair: done" }
}
}
}
}A no-op that mirrors the same shape. The leading invariant comment is important — keep it:
src/androidRelease/kotlin/com/example/yourapp/debug/DebugToolsImpl.kt:
// Invariant: this file must not import anything from com.lynxal.argus.*
// Enforced by :sample:verifyReleaseHasNoArgus (dexdump the release APK for
// com/lynxal/argus/, io/ktor/server/, com/lynxal/argus/webui/ — fail if any are present).
package com.lynxal.argus.sample.debug
import android.app.Application
import com.lynxal.logging.DebugLoggerImplementation
import com.lynxal.logging.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class DebugToolsImpl(@Suppress("unused") private val app: Application) : DebugTools {
private val empty: StateFlow<String?> = MutableStateFlow<String?>(null).asStateFlow()
override fun buildHttpClient(): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json()
}
}
override fun installLogging() {
Logger.add(DebugLoggerImplementation())
}
override fun observeArgusUrl(): StateFlow<String?> = empty
override fun stopArgus() {
// no-op in release
}
override fun publishCustom(source: String, label: String, payload: String) {
// no-op in release
}
override fun fireOkHttpCall(url: String) {
// no-op in release
}
override fun fireUrlConnectionCall(url: String) {
// no-op in release
}
override fun fireCorrelatedPair(first: String, second: String) {
// no-op in release
}
}Application code calls only DebugTools methods. The build variant decides which DebugToolsImpl is on the classpath.
src/androidMain/kotlin/com/example/yourapp/SampleApp.kt:
package com.lynxal.argus.sample
import android.app.Application
import com.lynxal.argus.sample.debug.DebugTools
import com.lynxal.argus.sample.debug.DebugToolsImpl
import io.ktor.client.HttpClient
class SampleApp : Application() {
lateinit var debugTools: DebugTools
private set
lateinit var httpClient: HttpClient
private set
override fun onCreate() {
super.onCreate()
debugTools = DebugToolsImpl(this)
debugTools.installLogging()
httpClient = debugTools.buildHttpClient()
}
}That's the full integration. Run a debug build, hit any HTTP endpoint, and Argus is capturing.
Every code block below is copied verbatim from :sample, which is gated by :sample:verifyIosReleaseHasNoArgus — an xcodebuild -configuration Release followed by a symbol scan of the produced framework. If the sample builds, this README is correct.
The iOS seam works the same way as Android (interface in shared code, real impl in a debug-only source dir, no-op impl in a release source dir) but the variant selection is driven by a Gradle property (-PargusEnabled) that the Xcode build phase script flips based on $CONFIGURATION instead of by the Android build type.
[!IMPORTANT] Argus iOS captures Ktor
HttpClienttraffic only. URLSession / Alamofire / native networking interception is not supported — your iOS app must use Ktor for HTTP if you want it on the timeline.
If your iOS app does not use Kotlin Multiplatform, consume Argus as an XCFramework via SPM:
https://github.com/lynxal/KMM-Argus.ArgusIOS library to your debug app target.#if DEBUG (or split debug/release schemes) so the released app does not link Argus:#if DEBUG
import ArgusIOS
let handle = Argus.shared.start { config in
config.port = 8787
}
#endifThe XCFramework is built by Gradle (./gradlew :argus-ios:assembleArgus-iosReleaseXCFramework) and published as a release asset on each Argus release. KMP-based apps should keep using implementation("com.lynxal.argus:argus-ios:1.0.0") from Maven Central — the steps below describe that path.
In your sample's build.gradle.kts, add iOS targets and a shared framework. Read the argusEnabled property at config time, conditionally add :argus-ios to iosMain deps, and swap the source dir between an enabled and disabled impl:
val argusEnabled: Boolean =
(findProperty("argusEnabled") as? String)?.toBoolean() ?: false
kotlin {
androidTarget { /* … */ }
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = "Sample"
isStatic = true
}
}
applyDefaultHierarchyTemplate()
sourceSets {
val iosMain by getting {
kotlin.srcDir(
if (argusEnabled) "src/iosArgusEnabledMain/kotlin"
else "src/iosArgusDisabledMain/kotlin"
)
dependencies {
implementation(libs.ktor.client.darwin)
if (argusEnabled) implementation(projects.argusIos)
}
}
}
}In your iosApp.xcodeproj, the Compile Kotlin Framework build phase passes -PargusEnabled based on $CONFIGURATION:
cd "$SRCROOT/../.."
if [ "$CONFIGURATION" = "Debug" ]; then
ARGUS_ENABLED=true
else
ARGUS_ENABLED=false
fi
./gradlew :sample:embedAndSignAppleFrameworkForXcode "-PargusEnabled=$ARGUS_ENABLED"The Xcode app target also needs OTHER_LDFLAGS = -lsqlite3 (Argus uses SqlDelight's NativeSqliteDriver for optional event persistence; the host app supplies the system sqlite link).
The interface defined in §4 Step 2 lives in commonMain/ and works for both Android and iOS — the Android impls under src/androidDebug/ + src/androidRelease/ and the iOS impls under src/iosArgusEnabledMain/ + src/iosArgusDisabledMain/ all satisfy the same shape. No duplication.
package com.lynxal.argus.sample.debug
import com.lynxal.argus.correlation.withCorrelation
import com.lynxal.argus.ios.Argus
import com.lynxal.argus.ios.ArgusHandle
import com.lynxal.argus.logging.ArgusLoggerDelegate
import com.lynxal.argus.model.Direction
import com.lynxal.argus.model.publishCustom
import com.lynxal.logging.DebugLoggerImplementation
import com.lynxal.logging.LogLevel
import com.lynxal.logging.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import com.lynxal.argus.ktor.Argus as ArgusPlugin
class DebugToolsImpl : DebugTools {
private val argus: ArgusHandle = Argus.start {
port = 8787
maxBodyBytes = 262_144L
}
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val ktorClient: HttpClient by lazy {
HttpClient(Darwin) {
install(ArgusPlugin) {
eventBus = argus.eventBus
maxBodyBytes = 262_144L
}
install(ContentNegotiation) {
json()
}
}
}
override fun buildHttpClient(): HttpClient = ktorClient
override fun installLogging() {
Logger.minLevel = LogLevel.Verbose
Logger.add(DebugLoggerImplementation())
Logger.add(ArgusLoggerDelegate(argus.eventBus))
}
override fun observeArgusUrl(): StateFlow<String?> = argus.url
override fun stopArgus() {
argus.stop()
}
override fun publishCustom(source: String, label: String, payload: String) {
argus.eventBus.publishCustom(
source = source,
label = label,
direction = Direction.NONE,
payload = payload,
)
}
override fun fireOkHttpCall(url: String) {
// OkHttp engine is JVM-only; no iOS counterpart.
}
override fun fireUrlConnectionCall(url: String) {
// HttpURLConnection is JVM-only; no iOS counterpart.
}
override fun fireCorrelatedPair(first: String, second: String) {
ioScope.launch {
withCorrelation {
val logger = Logger.tag("Argus sample")
logger.info { message = "correlated-pair: starting" }
runCatching { ktorClient.get(first) }
logger.info { message = "correlated-pair: first done, firing second" }
runCatching { ktorClient.get(second) }
logger.info { message = "correlated-pair: done" }
}
}
}
}The leading invariant comment is important — keep it.
// Invariant: this file must not import anything from com.lynxal.argus.*
// Enforced by :sample:verifyIosReleaseHasNoArgus (xcodebuild Release then nm/strings
// on the produced framework binary — fails if any com.lynxal.argus., kfun:com.lynxal.argus.,
// io.ktor.server., ArgusServer, or ArgusEventBus symbol is present).
package com.lynxal.argus.sample.debug
import com.lynxal.logging.DebugLoggerImplementation
import com.lynxal.logging.LogLevel
import com.lynxal.logging.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.darwin.Darwin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class DebugToolsImpl : DebugTools {
private val empty: StateFlow<String?> = MutableStateFlow<String?>(null).asStateFlow()
override fun buildHttpClient(): HttpClient = HttpClient(Darwin)
override fun installLogging() { Logger.add(DebugLoggerImplementation()) }
override fun observeArgusUrl(): StateFlow<String?> = empty
override fun stopArgus() {}
override fun publishCustom(source: String, label: String, payload: String) {}
override fun fireOkHttpCall(url: String) {}
override fun fireUrlConnectionCall(url: String) {}
override fun fireCorrelatedPair(first: String, second: String) {}
}In iosMain/, expose a single MainViewController() that constructs the right DebugToolsImpl (selected by source-dir swap) and wraps the Compose UI in a UIKit view controller:
package com.lynxal.argus.sample
import androidx.compose.ui.window.ComposeUIViewController
import com.lynxal.argus.sample.debug.DebugToolsImpl
import com.lynxal.argus.sample.ui.App
import platform.UIKit.UIViewController
fun MainViewController(): UIViewController {
val tools = DebugToolsImpl()
tools.installLogging()
return ComposeUIViewController {
App(
httpClient = tools.buildHttpClient(),
argusUrl = tools.observeArgusUrl(),
// …callbacks delegate to tools
)
}
}Swift entry-point (iosApp/iOSApp.swift):
import SwiftUI
import Sample
@main
struct ArgusSampleApp: App {
var body: some Scene {
WindowGroup { ContentView().ignoresSafeArea() }
}
}
struct ContentView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.MainViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}The Swift app calls only MainViewControllerKt.MainViewController() — it never imports com.lynxal.argus.*. Both Swift and Kotlin uphold the seam.
Run the iOS gate locally any time you change the build-phase script or seam wiring:
./gradlew :sample:verifyIosReleaseHasNoArgusIt runs xcodebuild -configuration Release -destination 'generic/platform=iOS Simulator' (no signing identity required), then scans the produced Sample.framework binary with strings for forbidden symbol fragments (kfun:com.lynxal.argus., io.ktor.server., ArgusServer, ArgusEventBus). The sample's own com.lynxal.argus.sample.* symbols are whitelisted.
[!NOTE] The iOS framework size grows considerably when Argus is linked (Debug ≈ 65 MB with debug symbols, ≈ 17 MB stripped Release). This is fine for a debug-only artifact — the Release framework, which is what ships, contains none of it.
Argus does not define a staging build type — that's a consumer concern. If your app has a staging variant and you want Argus there too:
staging build type in your app/build.gradle.kts (typically initWith debug).stagingImplementation("com.lynxal.argus:argus-android:1.0.0").src/staging/kotlin/.../debug/DebugToolsImpl.kt mirroring the debug source-set impl from §4.The same source-set seam pattern works for any number of variants. What it never does is leak Argus into release.
When Argus.start() succeeds, it logs the URL to logcat:
I/Argus: Argus listening on http://192.168.1.42:8787
Filter logcat for Argus and you'll see it on every debug launch. Open that URL in any browser on the same Wi-Fi and the inspector loads.
The URL is also exposed reactively:
debugTools.observeArgusUrl().collect { url ->
// show in a debug-only overlay or share sheet
}Surface startupError too. If the server can't bind — a pinned port already taken is the usual
cause — url simply stays null, which is indistinguishable from "still starting". ArgusHandle
exposes the reason as a second flow, so plumb it through the same seam and show it next to the URL:
// debug DebugToolsImpl
override fun observeArgusError(): StateFlow<String?> = argus.startupError
.map { it?.message }
.stateIn(scope, SharingStarted.Eagerly, null)A bind failure never takes the host app down; it is reported and nothing else. See
§12 for the port-conflict case and portFallback.
If logcat isn't handy (Canvas Hub firmware, headless device), enter the device's LAN IP and the configured port directly in the browser.
Event list. Single-column stream with source badge (HTTP/LOG/CUSTOM), method or log level, status pill, primary text (host in muted, path in primary), and meta (timestamp). Compact (28 px) and comfy (32 px) row densities. Keyboard navigation moves a 2 px focus rail down the left edge.
Detail tabs. The right pane in split view (above) shows a tabbed detail per event. HTTP events: Overview · Headers · Request · Response · Timing · cURL. Log events: Overview · Context · Stack. Custom events: Overview · Payload. Bodies render as syntax-highlighted JSON, plain text, hex+ASCII, or image preview based on content type.
Filters. Toggle source (HTTP/LOG/CUSTOM), method (GET/POST/PUT/PATCH/DELETE/OTHER), status class (2xx/3xx/4xx/5xx/ERR), and log level (ERROR/WARN/INFO/DEBUG/VERB) as filled chips. Add text filters for host, tag, and free-text contains. Active filters are tinted in the source's color.
Waterfall. Time axis with per-event tracks. HTTP requests stack as Connect / Wait / Download segments, each tinted by status; errored requests render as a dashed red bar. Log and custom events show as 2 px ticks at their timestamp. Zoom in/out from the header.
Export. Copy any event as cURL. Headers and bodies copy individually. The whole stream exports as JSON.
Keyboard shortcuts. / focuses search. j / k navigate the event list. 1 / 2 / 3 switch List / Split / Waterfall views. p pauses live ingest. ? opens the shortcut overlay.
Argus has two config surfaces: server-side (Argus.start { ... }) for the inspector server, and client-side (per-engine capture plugins) for what gets recorded.
| Option | Default | Description |
|---|---|---|
port |
0 (OS-assigned) |
TCP port for the embedded server. Pin (e.g. 8787) for a stable URL. If a pinned port is already in use, the server doesn't come up and the reason lands on ArgusHandle.startupError — your app keeps running either way. |
portFallback |
false |
When a pinned port can't be bound, rebind on an OS-assigned port instead of giving up. Off by default so a fixed-port setup never silently moves; ArgusHandle.url always reports the port actually bound. |
maxEvents |
500 |
Ring-buffer size. Older events are dropped beyond this. |
maxBodyBytes |
1_000_000 (1 MB) |
Per-body capture cap. Bodies larger than this are truncated. |
redactHeaders |
["Authorization", "Cookie", "Set-Cookie", "Proxy-Authorization"] |
HTTP header names whose values are replaced with ***redacted*** before capture. |
corsDevOrigins |
["http://localhost:5173"] |
Extra CORS origins for the dev web UI. The bundled production UI is served same-origin and needs no entry here. Set to emptyList() in any non-debug context to skip the CORS plugin install. |
persist |
false |
Persist events to disk so they survive process restarts. On next Argus.start(), the previous session's events are restored into the in-memory ring (capped at maxEvents). The UI does not surface older sessions — persist keeps the timeline alive across restarts, not a full archive. |
persistMaxSizeMb |
100 |
Soft cap on on-disk payload size (MB). Whichever fires first with persistMaxAgeDays prunes the oldest persisted events. |
persistMaxAgeDays |
7 |
Soft cap on persisted-event age (days). Whichever fires first with persistMaxSizeMb. |
Example (from :sample):
Argus.start(application) {
port = 8787
maxBodyBytes = 262_144L // 256 KB
}Argus.start() returns an ArgusHandle. That handle is the lifecycle: it carries the bound
URL, the failure reason, the event bus your capture plugins write into, and stop().
val argus: ArgusHandle = Argus.start(application) { port = 8787 }
argus.url // StateFlow<String?> — "http://192.168.1.42:8787" once bound, else null
argus.startupError // StateFlow<Throwable?> — why it isn't up, else null
argus.eventBus // wire into ArgusPlugin / ArgusOkHttpInterceptor / ArgusLoggerDelegate
argus.stop() // stop the server, release the portstart() is non-suspending and returns immediately. The socket binds on a background
dispatcher, so url is null for a short while after start() returns. That's why it's a
StateFlow and not a String — observe it, don't read it once. Call start() from your
debug-only Application.onCreate() (Android) or app init (iOS).
stop() releases the port and is safe to call anywhere. Specifically, it is:
It resets url and startupError to null. A stopped handle is spent: to run the inspector
again, call Argus.start() for a fresh handle rather than reusing the old one.
One caveat worth knowing: stop() blocks the calling thread for up to ~1.1 s while the engine
drains in-flight requests. If you're stopping from onDestroy() or anywhere on the main thread
and that matters to you, move the call to a background dispatcher.
The guide above starts Argus once in Application.onCreate(), which is what most apps want. If you
instead want to start and stop it from a debug menu, there's one thing to get right: every
Argus.start() creates a fresh server with a fresh event bus and a fresh ring buffer, and a
stopped handle is spent. Wire your capture plugins straight to handle.eventBus and after one
stop/start cycle they'll still be publishing into the previous run's buffer — the inspector comes
back up and shows nothing.
Give the plugins a stable bus that forwards to whichever run is current:
private class SwitchableEventBus : ArgusEventBus {
@Volatile var target: ArgusEventBus? = null
override fun publish(event: ArgusEvent) {
target?.publish(event) // dropped while the inspector is stopped
}
}Wire the plugins to that once, then on start set bus.target = handle.eventBus and on stop set it
back to null. :sample does exactly this — see its debug DebugToolsImpl for the whole thing,
including mirroring url/startupError onto flows that outlive an individual run.
Argus must never take the host app down. It is a debugging aid — a failure inside it is never worth a crash in your app. Concretely, and covered by tests on both Android and iOS:
| Situation | What happens |
|---|---|
| Pinned port already in use |
startupError is set, url stays null, app runs on. With portFallback = true, rebinds on a free port instead. |
| Any other bind failure | Same — the handling is errno-agnostic. |
| Engine dies after a successful bind | Reported to startupError. |
| The persistence DB can't be opened or migrated | Logged, and Argus continues in-memory with persist effectively off. |
Anything at all during stop()
|
Logged and swallowed. |
The one thing that does throw is ArgusServer.boundPort when read before a successful start —
it's a programming error, not a runtime condition. Read handle.url instead.
This is a contract, not a best effort, so surface startupError in your debug UI. Because
Argus fails quietly by design, a silent failure looks identical to "still starting" unless you
show the reason. :sample does this next to the URL — see
§10 — and §7 has the snippet.
These options live on each engine's plugin/interceptor config: Ktor install(ArgusPlugin) { ... }, OkHttp ArgusInterceptor's ArgusOkHttpConfig, and HttpURLConnection's ArgusUrlConnectionConfig. The Ktor shape is shown; the others mirror it field-for-field.
| Option | Default | Description |
|---|---|---|
eventBus |
NoopEventBus |
Sink for captured events. Set to argusHandle.eventBus so captures land in the inspector. |
maxBodyBytes |
1_000_000 (1 MB) |
Per-body capture cap. Bodies larger than this are truncated. |
redactHeaders |
(same default set as server) | Headers whose values are replaced with ***redacted*** before capture. |
captureRequestBody |
true |
Capture request bodies. Set to false to record only metadata. |
captureResponseBody |
true |
Capture response bodies. Set to false to record only metadata. |
fullBodyHosts |
emptySet() |
Hosts whose bodies bypass maxBodyBytes and are captured in full. Case-insensitive on URL host (no port, no scheme). Practical ceiling per body is ~2 GB (Int.MAX_VALUE) because captures are held in a single ByteArray; larger payloads are truncated and the event reports truncatedTotalBytes. Use sparingly. |
:sample is the canonical reference for both platforms. The same KMP module produces the Android APK and the iOS framework; Compose Multiplatform renders the shared UI on both.
Android:
git clone https://github.com/lynxal/KMM-Argus.git
cd argus
./gradlew :sample:installDebugLaunch on a device or emulator, hit a couple of buttons, and open the URL from logcat. You should see Argus working in two minutes.
The sample does not start Argus automatically. A status line at the top always says which state it's in — Argus: stopped, Argus: starting…, Argus: failed to start (with the reason beneath), or the bound URL — and a single button toggles between Start Argus and Stop Argus. That exercises the whole lifecycle from §9.3, including repeated stop/start cycles, which is why the sample routes its capture plugins through a switchable bus rather than holding one run's eventBus.
To watch the port-conflict path, get something else onto 8787 first — launch the sample twice, or adb forward tcp:8787 tcp:8787 — then press Start Argus: the status line reports the conflict and the app keeps running.
iOS: open sample/iosApp/iosApp.xcodeproj in Xcode and run on a simulator or device with the Debug scheme. The buttons are the same as Android; OkHttp and HttpURLConnection demos are no-ops on iOS (the engines are JVM-only).
Both gates run as part of :sample:check. Run them locally any time you change variant wiring:
./gradlew :sample:verifyReleaseHasNoArgus # Android: dexdumps the release APK
./gradlew :sample:verifyIosReleaseHasNoArgus # iOS: scans the Release framework binaryflowchart LR
consumer["consumer app<br/>(debug variant)"]
android["argus-android<br/><i>Android entry point</i>"]
ios["argus-ios<br/><i>iOS entry point</i>"]
server["argus-server-core<br/><i>Embedded Ktor server</i>"]
bundle["argus-webui-bundle<br/><i>Pre-built SPA (resource)</i>"]
core["argus-core<br/><i>Event model + Ktor capture plugin</i>"]
okhttp["argus-okhttp<br/><i>OkHttp interceptor</i>"]
urlconn["argus-urlconnection<br/><i>HttpURLConnection wrapper</i>"]
webui["argus-webui<br/><i>UI source (build-time)</i>"]
consumer --> android
consumer --> ios
consumer -.-> okhttp
consumer -.-> urlconn
android --> server
ios --> server
android --> core
ios --> core
okhttp --> core
urlconn --> core
server --> core
server -. bundles .- bundle
webui -. compiled into .- bundle| Module | Coordinates | Purpose |
|---|---|---|
argus-core |
com.lynxal.argus:argus-core:1.0.0 |
Shared model, ArgusClientPlugin (Ktor capture), event bus, redaction. |
argus-server-core |
com.lynxal.argus:argus-server-core:1.0.0 |
Embedded Ktor server: REST + WebSocket endpoints, event dispatcher, ArgusConfig. |
argus-webui-bundle |
com.lynxal.argus:argus-webui-bundle:1.0.0 |
Pre-built SPA shipped as a JVM resource the server statically serves. |
argus-android |
com.lynxal.argus:argus-android:1.0.0 |
Android entry point: Argus.start(), ArgusHandle, ArgusConfigBuilder. |
argus-ios |
com.lynxal.argus:argus-ios:1.0.0 |
iOS entry point for Apple targets: Argus.start(), ArgusHandle, ArgusConfigBuilder. Also published as an XCFramework via Swift Package Manager (see §5). |
argus-okhttp |
com.lynxal.argus:argus-okhttp:1.0.0 |
OkHttp Interceptor capture for non-Ktor JVM HTTP. |
argus-urlconnection |
com.lynxal.argus:argus-urlconnection:1.0.0 |
HttpURLConnection capture wrapper for legacy JVM HTTP. |
Why debug-only? See §3. The summary: the embedded server is a production-grade attack surface, and the seam-pattern source-set split (with the verifyReleaseHasNoArgus CI gate) is the only integration shape we support. There is no no-op artifact, by design — a missing release-side DebugToolsImpl is a build error, which is the desired failure mode.
Can't connect from desktop. Most common causes, in order:
adb reverse tcp:8787 tcp:8787 over USB.curl http://<device-ip>:8787/api/events.port = 8787 and another process owns it — a second instance of your app, most commonly — the server doesn't start. Your app is unaffected; the bind error shows up on ArgusHandle.startupError and in logcat as E/Argus: Argus start failed. Set portFallback = true to rebind on a free port instead, drop the pin (port = 0), or pick a different port. See §9.3 for the full contract.Release build fails to compile / link. Confirm src/release/.../debug/DebugToolsImpl.kt exists and has the same shape as src/debug/.../debug/DebugToolsImpl.kt but zero com.lynxal.argus.* imports. The release-source-set file is what makes the variant compile when Argus is absent.
Release APK contains Argus classes. Run :sample:verifyReleaseHasNoArgus (or the equivalent in your app) for the canonical diagnostic. Then:
./gradlew :app:dependencies --configuration releaseRuntimeClasspath | grep -i argusIf anything appears, you have an implementation, api, or releaseImplementation line pulling Argus in transitively (often via a shared library that itself uses implementation instead of debugImplementation). Convert it to debugImplementation.
Something else. :sample is the canonical working integration. Diff your variant wiring against it.
Issues and pull requests are welcome via GitHub. There is no CONTRIBUTING.md yet — the short version: fork, branch, run ./gradlew check :sample:verifyReleaseHasNoArgus, open a PR.
License: MIT — matching the licenses block every module's POM publishes to Maven Central.