Skip to content

Commit 3cf0499

Browse files
Hussein Habibi Juybariclaude
andcommitted
Release v0.14.0 — modern viewer redesign + correctness fixes
UI rewritten with command-search syntax, autocomplete, saved filters, 4 themes x dark/light, Logcat + Table views, context menu, JSON export. Adds PID/TID column matching adb logcat -v threadtime. Filtering: level: now matches both logs and HTTP rows (HTTP level derived from status). exclude:message: searches across all visible fields. status: 2xx/4xx/5xx shorthand supported. Bugs fixed: LogTapInterceptor honors active Config (redactHeaders + maxBodyBytes were ignored); LogStore.snapshot sinceId uses > instead of ==; SharedFlow now DROP_OLDEST; WebSocket handler runs on session scope; legacy LogTapEvents queue removed; LogTapSinkAdapter routes through LogStore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ca82082 commit 3cf0499

9 files changed

Lines changed: 1528 additions & 3275 deletions

File tree

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,39 @@ All notable changes to this project will be documented in this file.
33
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
44
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
55

6+
### v0.14.0
7+
Modern viewer redesign + correctness fixes
8+
9+
UI
10+
- Rewrite web viewer (`Resources.kt`) with a modern, theme-aware UX based on the LogTap Viewer design.
11+
- Smart command-search with autocomplete: `level:`, `method:`, `status:` (incl. `2xx`/`4xx`/`5xx` shorthand), `exclude:tag:`, `exclude:message:`, `exclude:method:`, `exclude:status:`. Keyboard navigation (↑/↓/Enter/Tab/Esc).
12+
- One-click favorites (☆/★) save the current query; saved-filters popover with badge count, click to apply, delete to remove. Persisted to localStorage.
13+
- Four themes (Android Studio, Xcode, Grafana, Modern) × dark/light mode. Persisted to localStorage.
14+
- Always-visible metrics (Total / Network / Logs / Errors / Logs/min / Avg Response / Active Filters).
15+
- Two view modes: **Logcat** (Android Studio-style stream with inline request/response/headers) and **Table** (resizable columns, expandable rows, request body under URL, response preview column).
16+
- Right-click context menu on any row: Copy URL / Copy as cURL / Copy Message / Copy as JSON / Exclude tag / Exclude message.
17+
- Smart auto-scroll (off when scrolled up, resumes at bottom). Connection status indicator in status bar with auto-reconnect.
18+
- App icon, name, version, and build number from `/api/info` shown in the toolbar brand.
19+
- PID/TID column in Table view and `pid-tid` prefix in Logcat view (matches `adb logcat -v threadtime`).
20+
- Cmd/Ctrl+K focuses search; Esc closes popovers. High-contrast text selection across themes.
21+
22+
Filtering logic
23+
- `level:` matches both logs and HTTP rows. HTTP rows now derive `level` from status (5xx→ERROR, 4xx→WARN, else INFO), so `level:ERROR` returns server errors as well as ERROR logs.
24+
- `exclude:message:` searches across visible fields (url, body, status, etc.), not just `message`.
25+
- All command/panel filters are case-insensitive.
26+
27+
Backend
28+
- `LogEvent` gains `pid` and `tid` fields. `LogStore.add` stamps `Process.myPid()` automatically.
29+
- HTTP request/response events are paired client-side into a single row with both bodies and headers.
30+
31+
Bug fixes
32+
- `LogTapInterceptor` now reads the active `LogTap.Config` instead of constructing fresh defaults; user-supplied `redactHeaders` and `maxBodyBytes` are honored.
33+
- `LogStore.snapshot(sinceId, limit)` now returns events newer than `sinceId` (was equality match).
34+
- `LogStore.stream` configured with `BufferOverflow.DROP_OLDEST`; live events no longer silently dropped under load.
35+
- WebSocket handler in `server.kt` now collects on the session's coroutine scope; no leaked collectors on session abort.
36+
- Removed the legacy `LogTapEvents` queue; logger output and HTTP/WS events flow through a single `LogStore`. `/logs` (deprecated) removed; use `/api/logs`.
37+
- `LogTapSinkAdapter` writes to the active `LogStore` so `/api/logs` and `/ws` agree on contents.
38+
639
### v0.12.0
740

841
- Add DeviceAppInfo support and update UI with dynamic app details

LogTap/src/main/java/com/github/husseinhj/logtap/LogTap.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ object LogTap {
5050
@Volatile private var processLock: FileLock? = null
5151

5252
internal var store: LogStore? = null
53+
@Volatile internal var activeConfig: Config = Config()
5354

5455
internal val json = kotlinx.serialization.json.Json {
5556
ignoreUnknownKeys = true
@@ -102,6 +103,7 @@ object LogTap {
102103
return
103104
}
104105

106+
activeConfig = config
105107
store = LogStore(config.capacity)
106108

107109
// Start engine off the main thread and catch startup errors

LogTap/src/main/java/com/github/husseinhj/logtap/interceptor/Interceptor.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,10 @@ class LogTapInterceptor : Interceptor {
110110
}
111111

112112
private fun redact(headers: Headers): Headers {
113+
val redactSet = LogTap.activeConfig.redactHeaders
113114
val b = headers.newBuilder()
114115
headers.names().forEach { name ->
115-
if (LogTap.Config().redactHeaders.any { it.equals(name, ignoreCase = true) }) {
116+
if (redactSet.any { it.equals(name, ignoreCase = true) }) {
116117
b.set(name, "(redacted)")
117118
}
118119
}
@@ -144,9 +145,10 @@ class LogTapInterceptor : Interceptor {
144145
val contentType = body.contentType()
145146
val charset: Charset = contentType?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
146147
val size = buffer.size
147-
val capped = buffer.clone().readByteString(minOf(size, LogTap.Config().maxBodyBytes)).toByteArray()
148+
val maxBytes = LogTap.activeConfig.maxBodyBytes
149+
val capped = buffer.clone().readByteString(minOf(size, maxBytes)).toByteArray()
148150
val display = if (isPlainText(Buffer().write(capped))) String(capped, charset) else "(${capped.size} bytes binary)"
149-
val truncated = size > LogTap.Config().maxBodyBytes
151+
val truncated = size > maxBytes
150152
display to truncated
151153
} catch (_: Exception) {
152154
"(unable to read request body)" to true
@@ -192,7 +194,7 @@ class LogTapInterceptor : Interceptor {
192194
}
193195

194196
return try {
195-
val max = LogTap.Config().maxBodyBytes
197+
val max = LogTap.activeConfig.maxBodyBytes
196198

197199
// Use a PEAK at the source to avoid consuming downstream
198200
val source = body.source()
Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
package com.github.husseinhj.logtap.log
22

3-
import kotlinx.coroutines.flow.SharedFlow
43
import kotlinx.serialization.Serializable
54
import kotlinx.serialization.EncodeDefault
6-
import kotlinx.coroutines.flow.asSharedFlow
7-
import kotlinx.coroutines.flow.MutableSharedFlow
8-
import kotlinx.coroutines.channels.BufferOverflow
95
import kotlinx.serialization.ExperimentalSerializationApi
106

117
@Serializable
@@ -36,30 +32,6 @@ internal data class LogEvent(
3632
@EncodeDefault val thread: String = Thread.currentThread().name,
3733
val level: String? = null, // "DEBUG", "INFO", "WARN", ...
3834
val tag: String? = null, // Android log tag (caller class)
35+
val pid: Int? = null, // Process ID (own PID for in-app logs; logcat bridge passes app PID)
36+
val tid: Int? = null, // Thread ID (when known, e.g. from logcat threadtime)
3937
)
40-
41-
internal object LogTapEvents {
42-
private val seq = java.util.concurrent.atomic.AtomicLong(1L)
43-
private val queue = java.util.concurrent.ConcurrentLinkedQueue<LogEvent>()
44-
45-
private val _updates = MutableSharedFlow<LogEvent>(
46-
replay = 0,
47-
extraBufferCapacity = 1024,
48-
onBufferOverflow = BufferOverflow.DROP_OLDEST
49-
)
50-
fun updates(): SharedFlow<LogEvent> = _updates.asSharedFlow()
51-
52-
fun push(ev: LogEvent) {
53-
queue.add(ev)
54-
_updates.tryEmit(ev) // <-- broadcast to WS listeners
55-
}
56-
57-
fun nextId(): Long = seq.getAndIncrement()
58-
59-
/** Oldest -> newest, limited. */
60-
fun snapshot(limit: Int): List<LogEvent> {
61-
if (limit <= 0) return emptyList()
62-
val all = queue.toList()
63-
return if (all.size <= limit) all else all.takeLast(limit)
64-
}
65-
}

LogTap/src/main/java/com/github/husseinhj/logtap/log/LogStore.kt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
11
package com.github.husseinhj.logtap.log
22

3+
import android.os.Process
34
import java.util.ArrayDeque
45
import kotlinx.coroutines.sync.Mutex
56
import kotlinx.coroutines.sync.withLock
67
import java.util.concurrent.atomic.AtomicLong
78
import kotlinx.coroutines.flow.MutableSharedFlow
9+
import kotlinx.coroutines.channels.BufferOverflow
810

911
internal class LogStore(private val capacity: Int) {
1012
private val deque = ArrayDeque<LogEvent>(capacity)
1113
private val nextId = AtomicLong(1)
1214
private val mutex = Mutex()
13-
val stream = MutableSharedFlow<LogEvent>(replay = 0, extraBufferCapacity = 512)
15+
private val ownPid = Process.myPid()
16+
val stream = MutableSharedFlow<LogEvent>(
17+
replay = 0,
18+
extraBufferCapacity = 512,
19+
onBufferOverflow = BufferOverflow.DROP_OLDEST
20+
)
1421

1522
suspend fun add(event: LogEvent) {
16-
val withIds = event.copy(id = nextId.getAndIncrement())
23+
val withIds = event.copy(
24+
id = nextId.getAndIncrement(),
25+
pid = event.pid ?: ownPid
26+
)
1727
mutex.withLock {
1828
if (deque.size == capacity) deque.removeFirst()
1929
deque.addLast(withIds)
@@ -28,7 +38,7 @@ internal class LogStore(private val capacity: Int) {
2838
suspend fun snapshot(sinceId: Long? = null, limit: Int = 500): List<LogEvent> {
2939
return mutex.withLock {
3040
val all = deque.toList()
31-
val filtered = sinceId?.let { id -> all.filter { it.id == id } } ?: all
41+
val filtered = sinceId?.let { id -> all.filter { it.id > id } } ?: all
3242
filtered.takeLast(limit)
3343
}
3444
}
Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
11
package com.github.husseinhj.logtap.logger
22

3+
import com.github.husseinhj.logtap.LogTap
34
import com.github.husseinhj.logtap.log.LogEvent
45
import com.github.husseinhj.logtap.log.Direction
56
import com.github.husseinhj.logtap.log.EventKind
6-
import com.github.husseinhj.logtap.log.LogTapEvents
7+
import kotlinx.coroutines.CoroutineScope
8+
import kotlinx.coroutines.Dispatchers
9+
import kotlinx.coroutines.SupervisorJob
10+
import kotlinx.coroutines.launch
711

812
/**
9-
* A Logcat sink that pushes log messages into LogTap event store.
10-
* To start capturing logcat logs, call:
13+
* A Logcat sink that pushes log messages into the active LogTap event store.
1114
* ### Example usage:
1215
* ```kotlin
1316
* LogTapLogcatBridge.start(LogTapSinkAdapter())
1417
* ```
1518
*/
1619
class LogTapSinkAdapter : LogTapLogcatBridge.Sink {
20+
21+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
22+
1723
override fun onLog(priority: Char, tag: String, message: String, threadId: Int?, time: String?) {
18-
val id = LogTapEvents.nextId()
1924
val now = System.currentTimeMillis()
20-
2125
val level = when (priority) {
2226
'V' -> "VERBOSE"
2327
'D' -> "DEBUG"
@@ -27,17 +31,17 @@ class LogTapSinkAdapter : LogTapLogcatBridge.Sink {
2731
'A', 'F' -> "ASSERT"
2832
else -> "LOG"
2933
}
30-
LogTapEvents.push(
31-
LogEvent(
32-
id = id,
33-
ts = now,
34-
kind = EventKind.LOG,
35-
direction = Direction.STATE,
36-
summary = message,
37-
thread = (threadId?.toString() ?: Thread.currentThread().name),
38-
level = level,
39-
tag = tag
40-
)
34+
val event = LogEvent(
35+
id = 0,
36+
ts = now,
37+
kind = EventKind.LOG,
38+
direction = Direction.STATE,
39+
summary = message,
40+
thread = threadId?.toString() ?: Thread.currentThread().name,
41+
level = level,
42+
tag = tag,
43+
tid = threadId
4144
)
45+
scope.launch { LogTap.store?.add(event) }
4246
}
43-
}
47+
}

LogTap/src/main/java/com/github/husseinhj/logtap/server/server.kt

Lines changed: 13 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,12 @@ import android.content.Context
66
import kotlin.text.toIntOrNull
77
import io.ktor.http.ContentType
88
import kotlin.text.toLongOrNull
9-
import kotlinx.coroutines.cancel
10-
import kotlinx.coroutines.launch
119
import io.ktor.server.routing.get
1210
import io.ktor.server.routing.post
1311
import io.ktor.server.routing.routing
14-
import kotlinx.coroutines.Dispatchers
1512
import io.ktor.server.application.call
1613
import io.ktor.server.engine.connector
1714
import io.ktor.server.response.respond
18-
import kotlinx.coroutines.CoroutineScope
1915
import com.github.husseinhj.logtap.LogTap
2016
import io.ktor.server.application.install
2117
import io.ktor.server.websocket.webSocket
@@ -29,7 +25,6 @@ import com.github.husseinhj.logtap.LogTap.store
2925
import com.github.husseinhj.logtap.log.LogEvent
3026
import com.github.husseinhj.logtap.utils.buildInfo
3127
import com.github.husseinhj.logtap.utils.Resources
32-
import com.github.husseinhj.logtap.log.LogTapEvents
3328
import io.ktor.server.engine.applicationEngineEnvironment
3429
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
3530

@@ -48,18 +43,11 @@ internal fun provideWebServer(port: Int, engineParentCtx: CoroutineContext, cont
4843
get("/app.css") { call.respondText(Resources.appCss, ContentType.Text.CSS) }
4944
get("/app.js") { call.respondText(Resources.appJs, ContentType.Application.JavaScript) }
5045

51-
get("/logs") {
52-
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 200
53-
call.respond(LogTapEvents.snapshot(limit))
54-
}
5546
get("/api/logs") {
5647
val sinceId = call.request.queryParameters["sinceId"]?.toLongOrNull()
5748
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 500
58-
store?.let {
59-
call.respond(it.snapshot(sinceId, limit))
60-
} ?: run {
61-
call.respond(emptyList<LogEvent>())
62-
}
49+
val snapshot = store?.snapshot(sinceId, limit) ?: emptyList()
50+
call.respond(snapshot)
6351
}
6452
get("/api/info") {
6553
val info = context.buildInfo() ?: return@get call.respondText("Unavailable", status = io.ktor.http.HttpStatusCode.InternalServerError)
@@ -70,30 +58,26 @@ internal fun provideWebServer(port: Int, engineParentCtx: CoroutineContext, cont
7058
call.respondText("ok")
7159
}
7260
webSocket("/ws") {
73-
val session = this
74-
val collector = CoroutineScope(Dispatchers.IO).launch {
75-
store?.stream?.collect { ev: LogEvent ->
76-
session.send(Frame.Text(json.encodeToString(LogEvent.serializer(), ev)))
77-
}
78-
}
79-
val backlog = LogTapEvents.snapshot(200)
61+
// Send backlog from the same store the live stream feeds from
62+
val backlog = store?.snapshot(null, 200) ?: emptyList()
8063
for (ev in backlog) {
8164
send(Frame.Text(json.encodeToString(LogEvent.serializer(), ev)))
8265
}
83-
val job = launch(Dispatchers.Default) {
84-
LogTapEvents.updates().collect { ev ->
66+
// Stream new events on this session's coroutine scope
67+
val s = store
68+
if (s != null) {
69+
s.stream.collect { ev ->
8570
try {
8671
send(Frame.Text(json.encodeToString(LogEvent.serializer(), ev)))
87-
} catch (_: Throwable) { cancel() }
72+
} catch (_: Throwable) {
73+
return@collect
74+
}
8875
}
89-
}
90-
try {
76+
} else {
9177
for (frame in incoming) { if (frame is Frame.Close) break }
92-
} finally {
93-
job.cancel(); collector.cancel()
9478
}
9579
}
9680
get("/about") { call.respondText(Resources.aboutHtml) }
9781
}
9882
}
99-
}
83+
}

0 commit comments

Comments
 (0)