Inspect and mock your Android app's network traffic β right inside the IDE.
No proxy. No certificates. No adb logcat | grep.
LogPose is named after the navigational device from One Piece that reads an island's "log" to point you the right way. This one reads your logcat and points you straight at the request you care about β then lets you serve a mock back to the device without touching backend or app code. HTTP traffic and FCM pushes land in one unified timeline.
- Your coding agent can read the running app β LogPose exposes the live capture over MCP, so Claude Code (or any MCP client) can answer "what did the app actually request, and why did it fail?" from real traffic instead of a pasted log line β and can create a mock to reproduce or unblock a state. See Connect a coding agent.
- Database, background work and remote config, first class β Room queries show their operation and table, a WorkManager request occupies one row that updates as it runs (and badges its retries), and a config activation reports exactly which flags changed, with before β after. See Database, workers and config.
- Log anything else too β
LogPose.event("PaymentSheet") { β¦ }puts any other subsystem on the same timeline. Events carry their own presentation, so they render with no plugin changes. See Log your own events. - Mock & replay β right-click any captured request β Mock this endpoint and serve a
response instead of hitting the network. Replace the whole body, or merge your JSON
into the real response to override a single field and leave the rest backend-generated.
Edit the response field by field (fold, tick what to override, see the original beside
your change), inject latency / timeouts / connection failures, cap serves, match paths with
*. Rules sync over adb, show hit counts, and clear when capture stops. - FCM in the same timeline β Firebase pushes and token refreshes appear inline with HTTP traffic, so you can read push β API call β UI as one story. Notification, metadata and data payload are all inspectable.
- Modern "Studio" tool window β a master/detail view with color-coded method/status pill badges, a hero Overview card (status, URL, duration/size/started/host/id stat chips), and side-by-side Request / Response cards.
- Duplicate detection β repeated calls in a burst get a
DUP ΓNtag; overlapping non-idempotent calls (likely double-submits) are flagged in red. - Copy the timeline β multi-select rows (
β§β/β) and copy the call sequence as plain text, ideal for pasting into a bug report. - Collapsible JSON trees β request/response bodies are parsed back into navigable,
syntax-colored trees; bodies that are JSON nest directly under
body. Toggle Tree / Raw (raw is syntax-highlighted too). - Find in body β
βF/Ctrl+Finside either card highlights all matches with next/prev navigation and ann/totalcounter. - One-click filter bar β a compact URL search box plus Method (GET/POST/PUT/DELETE) and Status (2xxβ5xx) toggles, and a Hide noise switch. No typing required.
- Mute noisy endpoints β right-click β mute; muted calls stay visible but fade into the background (numeric path segments are normalized, so one mute covers all ids). Persists across restarts.
- Copy everything β Copy as cURL (hover a row or right-click), Copy as JSON (per-section or the whole transaction), Copy URL, Copy response body.
- First-class multipart uploads β S3/GCS media uploads show per-part metadata, never raw bytes.
- Atomic, ordered capture β no interleaved or mismatched bodies, even under load; oversized payloads are chunked and reassembled.
The usual setup β OkHttp's HttpLoggingInterceptor at BODY level dumped into logcat β
breaks down fast:
- Bodies get mismatched.
HttpLoggingInterceptoremits many separateLoglines per call. Concurrent requests on different threads interleave, so request/response bodies get switched. - Too much noise, mixed in with every other app log.
- No expand/collapse β it's a flat text stream.
- Pretty JSON eats the screen β it's all-or-nothing.
- Large bodies get truncated (logcat caps entries at ~4 KB) and multipart media uploads (S3 / GCS) are unreadable binary dumps.
- Hard to filter to "just the
/orderscalls" or "only 5xx".
The root cause is that logcat is the wrong layer. LogPose fixes it by emitting one structured transaction per HTTP exchange and rendering it in a real UI.
βββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
β Android app β β Android Studio / IntelliJ β
β β one JSON line β LogPose tool window β
β LogPose interceptor β per exchange β β’ list of transactions β
β builds ONE Transaction β βββ(logcat)βββββΆ β β’ expand / collapse β
β (request + response) β tag: LogPose β β’ pretty JSON β
β β β β’ filter / search β
βββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
- The on-device interceptor serializes the whole request+response exchange into a
single JSON object and logs it as one line under the
LogPosetag. Atomic emission is what eliminates interleaving and mismatched bodies. - The plugin runs
adb logcat -v raw -s LogPose:V(only our tag, raw payloads β no noise), parses each line, and renders a filterable master/detail view. - Payloads bigger than a logcat line are split into ordered chunks and reassembled by the plugin.
- Multipart uploads ship per-part metadata (name, filename, content-type, size) β not raw bytes β so media uploads stay readable and cheap.
The plugin talks to
adbdirectly and does not depend on the bundled Android plugin, so it works in any JetBrains IDE.
The contract between the device and the plugin is a single JSON object per line: an
envelope carrying an opaque payload. The plugin only needs the envelope to place a row on
the timeline, which is what lets an app emit a kind the plugin has never heard of.
An http payload β the same shape LogPose has always emitted, now nested under payload:
{
"id": "a1b2c3",
"startedAtMillis": 1733500000000,
"durationMillis": 142,
"request": {
"method": "POST",
"url": "https://api.example.com/v1/orders",
"host": "api.example.com",
"path": "/v1/orders",
"headers": { "Content-Type": "application/json" },
"body": { "contentType": "application/json", "sizeBytes": 57, "text": "{...}" }
},
"response": {
"code": 200,
"message": "OK",
"headers": { "Content-Type": "application/json" },
"body": { "contentType": "application/json", "sizeBytes": 1203, "text": "{...}", "truncated": false }
}
}Multipart upload body example (no raw bytes):
"body": {
"contentType": "multipart/form-data",
"parts": [
{ "name": "file", "filename": "receipt.jpg", "contentType": "image/jpeg", "sizeBytes": 824123 },
{ "name": "meta", "contentType": "application/json", "sizeBytes": 64 }
]
}An event payload β a self-describing app event, rendered without any plugin support:
{
"title": "UserDao.insert",
"subtitle": "users (3 rows)",
"badges": [ { "text": "DB", "tone": "info" } ], // tones, never colors
"sections": [ { "label": "SQL", "type": "code", "body": "INSERT INTO users β¦" } ]
}Chunk envelope (for oversized payloads β unchanged, and wraps any of the above):
{ "id": "a1b2c3", "seq": 0, "total": 3, "payload": "<json-fragment>" }Reverse-channel control messages (hello, mock_ack) are deliberately not enveloped:
they're a separate IDE β device protocol, not timeline rows.
Bodies stay opaque to the transport, and presentation stays semantic β a badge carries a tone and a section carries a type, never a color or a layout, so a theme change can never become a wire break.
See Transaction.kt
for the canonical schema. Plugin 1.5.0 still reads the pre-1.3.0 un-enveloped format, so an old
logpose-android keeps working while you upgrade.
Filtering is a one-line, zero-typing bar (all conditions AND-ed):
| Control | Effect |
|---|---|
| Search box | URL/path contains the text (case-insensitive) |
| METHOD GET / POST / PUT / DELETE | multi-select; show only the picked methods |
| STATUS 2xx / 3xx / 4xx / 5xx | multi-select; show only the picked status classes |
| Hide noise switch | hide muted/noise endpoints entirely (right-click a row β mute to mark it noise) |
LogPose has two halves and you need both: the IDE plugin (reads logcat) and a one-line OkHttp interceptor in your app (emits the structured transactions the plugin reads).
From a release zip (until it's on the JetBrains Marketplace):
- Download
logpose-<version>.zipfrom Releases. - Android Studio / IntelliJ β Settings β Plugins β βοΈ β Install Plugin from Diskβ¦
- Pick the zip and restart. A LogPose tool window appears at the bottom.
Or build it yourself:
git clone https://github.com/siddharthjaswal/logpose.git
cd logpose
./gradlew buildPlugin # zip in build/distributions/
# or: ./gradlew runIde # launch a sandbox IDE with the plugin loadedThe interceptor is distributed via JitPack:
// settings.gradle.kts
dependencyResolutionManagement {
repositories { maven("https://jitpack.io") }
}
// app/build.gradle.kts
dependencies {
// Debug builds: the real interceptor.
debugImplementation("com.github.siddharthjaswal.logpose:logpose-android:v1.3.0")
// Release builds: a zero-overhead no-op with the SAME api β keeps LogPose out of
// production entirely (no logcat output, no kotlinx-serialization, zero transitive deps).
releaseImplementation("com.github.siddharthjaswal.logpose:logpose-no-op:v1.3.0")
}val client = OkHttpClient.Builder()
// Add LAST so LogPose sees the final request and the decoded response.
// Compiles unchanged in both variants β the no-op exposes the same LogPoseInterceptor.
.addInterceptor(LogPoseInterceptor(LogPoseConfig(enabled = BuildConfig.DEBUG)))
.build()With the debug/release split above, the release build links against the no-op stub, so
LogPose is gone from production by construction. enabled = BuildConfig.DEBUG is then just
belt-and-suspenders. (Prefer a single artifact in all variants? Use only the
debugImplementation line and rely on the enabled flag.) See
logpose-android/README.md for config (body-size limits,
header redaction, custom tag, custom transport).
LogPose can show Firebase Cloud Messaging pushes and token refreshes inline in the same
timeline as your HTTP traffic. Since a push isn't OkHttp traffic, you hand each one to
LogPose from your FirebaseMessagingService. LogPose stays Firebase-free β you copy the
fields you care about into a plain FcmMessageInfo (the no-op exposes the same API, so this
compiles and disappears in release):
class MyMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(m: RemoteMessage) {
LogPose.logFcmMessage(
FcmMessageInfo(
messageId = m.messageId,
from = m.from,
collapseKey = m.collapseKey,
sentTimeMillis = m.sentTime,
ttlSeconds = m.ttl,
priority = m.priority,
notificationTitle = m.notification?.title,
notificationBody = m.notification?.body,
data = m.data,
),
LogPoseConfig(enabled = BuildConfig.DEBUG),
)
super.onMessageReceived(m)
}
override fun onNewToken(token: String) {
LogPose.logFcmToken(token, LogPoseConfig(enabled = BuildConfig.DEBUG))
}
}FCM rows show up with an FCM tag and a NOTIF / DATA / TOKEN badge; selecting one
opens the notification, metadata (from, priority, ttl, collapse key), and the data payload
as a JSON tree. Use the TYPE filter (NET / FCM) to narrow the stream.
- Open the LogPose tool window (bottom edge).
- Click βΆ to start capturing β it clears the device log buffer and tails new traffic.
- Run your app on a device/emulator. Transactions stream in live.
- Filter by method/status/URL, click a row to inspect the JSON, Copy as cURL, mute noisy endpoints, etc.
Multiple devices attached? LogPose currently uses the default
adbdevice β a picker is on the roadmap.
LogPose can serve responses back to the app instead of hitting the network β reproduce a state-dependent bug, test an error/empty UI, or inject latency and failures, all without touching backend or app code.
- Right-click any captured request β "Mock this endpointβ¦".
- Edit the status, body, headers; optionally add latency, cap the number of serves, or set
the behavior to timeout / connection failure. The path pattern accepts
*wildcards (e.g./app/v4/*/order/*).- Replace mode serves your body as the whole response.
- Merge mode keeps the real backend response and deep-merges your JSON on top β change
one field or add keys while everything else stays backend-generated (needs
logpose-androidβ₯ 1.2.0).
- The rule appears in the Mocks strip under the filter bar (toggle, edit, delete, live hit counts, "Disable all"). While capture is running, matching requests are served locally and the row shows a purple MOCK pill β the timeline always reflects what the app actually received.
Rules are pushed to the device over adb (am broadcast to a receiver gated by the DUMP
permission, which only the adb shell holds β third-party apps can't reach it). The mock
machinery ships only in the real logpose-android artifact (never the release no-op), and
rules are cleared automatically when you stop capturing. Requires logpose-android β₯ 1.1.0.
Three kinds LogPose understands without being told how to draw them β you supply the facts, the IDE decides how they read.
Database β one line in a Room builder covers every query:
Room.databaseBuilder(app, AppDb::class.java, "app-db")
.apply {
if (BuildConfig.DEBUG) setQueryCallback({ sql, args ->
LogPose.logDbQuery(DbQueryInfo(sql = sql, args = args.map { it.toString() },
database = "app-db"))
}, Executors.newSingleThreadExecutor())
}
.build()The row reads users Β· SELECT id, name FROM users WHERE id = ? β operation and table are parsed
from the statement, so nothing extra has to be passed. Reads are toned quietly and writes stand
out. Pass durationMillis if you measure it; Room's callback doesn't provide one.
Background work β one observer covers every worker, including ones written later:
WorkManager.getInstance(this)
.getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.values().toList()))
.observeForever { infos ->
infos.forEach { info ->
LogPose.logWorker(WorkerEventInfo(
worker = info.tags.firstOrNull { it.contains('.') }?.substringAfterLast('.') ?: "Worker",
state = info.state.name.lowercase(),
workId = info.id.toString(),
runAttempt = info.runAttemptCount,
tags = info.tags.toList(),
))
}
}Because the event carries the request's workId, enqueued β running β succeeded update one
row instead of stacking up three. Durations come from state changes, so they include queue
time β the detail says so.
Remote config β hand over the whole snapshot and LogPose reports the diff, since Firebase gives you a map and a boolean, not a list of what changed:
firebaseRemoteConfig.fetchAndActivate().addOnCompleteListener {
LogPose.logConfigSnapshot(
firebaseRemoteConfig.all.mapValues { it.value.asString() },
source = "remote",
config = LogPoseConfig(enabled = BuildConfig.DEBUG),
)
}One row per activation β 3 flags changed Β· IS_CAMERAX_ENABLED, β¦ β with before β after in the
detail. The first snapshot after launch is recorded as a baseline rather than reporting all 187
flags as new, and a fetch that changed nothing costs no row at all.
All three need logpose-android β₯ 1.4.0, and dbEnabled / workersEnabled on LogPoseConfig
turn them off without unpicking the integration.
HTTP and FCM are just two kinds on the timeline. Any subsystem can put a row there, and the plugin needs no knowledge of it β the event describes its own presentation:
LogPose.event("UserDao.insert") {
subtitle = "users (3 rows)"
badge("DB", Tone.INFO)
took(14)
code("SQL", "INSERT INTO users (id, name) VALUES (?, ?)")
kv("Params", mapOf("id" to "7", "name" to "Vikram"))
}Sections are text, code, json, or kv; badge tones are semantic (INFO / WARN /
ERROR / MUTED) and get mapped onto the active IDE theme. Pass config the same way as the
interceptor so it no-ops in release:
LogPose.event("SyncWorker", LogPoseConfig(enabled = BuildConfig.DEBUG)) { β¦ }Group related events with a trace so a push, the calls it triggered, and the write that followed read as one flow:
val trace = LogPose.newTraceId()
LogPose.event("Push received") { traceId = trace }
LogPose.event("Feed refresh") { traceId = trace }For a payload something else already understands, there's a raw escape hatch β
LogPose.log(kind = "acme.telemetry", payloadJson = """{"metric":"frame_time_p99"}"""). Any
unrecognised kind still gets a row and an inspectable payload rather than being dropped.
The public API takes only strings and maps, so the release logpose-no-op artifact mirrors it
exactly and your call sites compile unchanged. Requires logpose-android β₯ 1.3.0.
Want to see it without wiring up an app?
./scripts/emit-demo-events.shwrites a few synthetic events straight to a connected device's logcat.
An agent working in your repo can read the code but has no idea what the running app is doing. LogPose fills that gap over MCP.
In the LogPose tool window, click β‘ Connect Coding Agent β it copies a ready-to-run command:
claude mcp add --transport http logpose http://localhost:63342/api/logpose/mcp \
--header "X-LogPose-Token: <your project token>"Then ask for things you'd otherwise dig for by hand:
"Using logpose, what failed in the last minute?" "Mock /app/v1/orders to return a 500 so I can check the error state."
Tools: list_events, get_event, get_trace, find_failures, session_summary,
list_mocks, create_mock, set_mock_enabled, delete_mock.
It serves on the IDE's own built-in web server (localhost, default port 63342 β check the copied command, since a second IDE gets the next free port). A few things worth knowing:
- Every call is authenticated with a per-project token, which also selects which open project's capture to serve. Captures contain auth tokens and user data, and that port is reachable by any local process.
create_mockchanges what the running app receives. Your MCP client asks before each call, rules show up in the Mocks strip like any other, and everything clears from the device when capture stops.- Response bodies can be withheld while still exposing request shape, statuses, and
timings β set
logpose.mcp.exposeBodiestofalsein the project's properties.
logpose/
βββ src/β¦ # the IntelliJ / Android Studio plugin (this build)
βββ logpose-android/ # the drop-in OkHttp interceptor (separate Gradle build)
The two halves talk over the wire format above β the interceptor
emits it, the plugin reads it. See logpose-android/README.md
for the device-side setup.
- Plugin: tool window, logcat capture, master/detail, chunk reassembly
-
logpose-androidinterceptor: atomic transaction, multipart metadata, gzip, header redaction, chunking - Collapsible JSON tree + real JSON editor (folding) in Raw mode
- Copy as cURL / JSON, endpoint muting, one-click filter bar, find-in-body
- Live in-flight requests β appear on hit, ticking timer + loader until the response
- Modern "Studio" card UI, custom icon, light & dark theme
- Interceptor published on JitPack β
com.github.siddharthjaswal.logpose:logpose-android:v1.3.0(nomavenLocalneeded);jitpack.ymlbuilds thelogpose-androidsubproject. - No-op release artifact β
com.github.siddharthjaswal.logpose:logpose-no-op:v1.3.0lets you strip LogPose from release builds viareleaseImplementation(same API, zero deps). - Plugin published on the JetBrains Marketplace
β search "LogPose" in Plugins; signing + publishing wired via GitHub Actions (
RELEASING.md). - Maven Central for the interceptor (optional, more "official" than JitPack).
- CI (GitHub Actions):
buildPlugin+verifyPluginon push/PR; GitHub Release on tag. - Plugin compatibility verified (Plugin Verifier vs 2024.1 / 2024.3;
since-build 233, no upper bound; bundled JSON module). -
CHANGELOG.md+<change-notes>inplugin.xml; semantic versioning. - Security/privacy: documented β runs on debug/staging only,
Authorization& cookies redacted on-device, bodies never leave logcat. - Tests for the pure logic:
TransactionParser(incl. chunk reassembly),CurlBuilderquoting,FilterStatematching,MutedEndpoints.normalize, body capture (multipart/binary/gzip/truncation). (the main remaining item)
- Per-device picker when multiple devices/emulators are attached.
- Settings panel (tag, body limits, default filters) instead of code-only config.
- Optional socket transport (
adb reverse) to bypass logcat truncation entirely. - Persist/replay captured sessions; export HAR.
- Zero-setup "raw OkHttp" capture mode (parse stock
HttpLoggingInterceptoroutput).
Issues and PRs welcome. This is pre-1.0 β the wire format may still change.






{ "v": 1, "kind": "http", // "http" | "fcm" | "event" | anything you define "id": "a1b2c3", // correlates request + response; re-emit to update in place "at": 1733500000000, // span start (device epoch millis) "endedAt": 1733500000142, // null = still open Β· == at = point in time Β· > at = a span "traceId": "f00d", // optional, groups related events "parentId": null, "payload": { /* kind-specific, see below */ } }